PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 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.5.0, at includes/seo/class-seo-analyzer.php

2,026 lines 83.3 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 // Check result statuses.
53 public const PASSED = 'passed';
54 public const WARNING = 'warning';
55 public const FAILED = 'failed';
56
57 /**
58 * Human-readable labels for each category id.
59 *
60 * @return array<string,string>
61 */
62 private function get_category_labels(): array {
63 return [
64 'basic' => __('Basic SEO', 'thinkrank'),
65 'advanced' => __('Advanced SEO', 'thinkrank'),
66 'content' => __('Content', 'thinkrank'),
67 'performance' => __('Performance & Technical', 'thinkrank'),
68 'security' => __('Security', 'thinkrank'),
69 // Generative/answer engine optimization. Spelled out because the
70 // acronym alone reads as geography, and ucfirst()'d "Geo" — what a
71 // filter-registered category falls back to — reads as nothing.
72 'geo' => __('AI Search (GEO)', 'thinkrank'),
73 ];
74 }
75
76 /**
77 * WordPress options whose value the analyzer reports on directly.
78 *
79 * @since 2.2.0
80 * @var string[]
81 */
82 private const WATCHED_OPTIONS = [
83 'blog_public',
84 'permalink_structure',
85 'blogname',
86 'blogdescription',
87 // The published llms.txt document. Publishing or clearing it flips a
88 // GEO check, and it is written as an option rather than through the
89 // settings manager, so the settings-saved hook never sees it.
90 'thinkrank_llms_txt_content',
91 ];
92
93 /**
94 * Register cache invalidation.
95 *
96 * The analysis is cached for an hour, and until now only the image alt-text
97 * bulk writer ever busted it — so changing any other setting the audit
98 * reports on left the screen confidently wrong for up to 60 minutes. The
99 * audit's whole job is to describe the site's current configuration, so it
100 * invalidates on every write it could possibly be reading.
101 *
102 * @since 2.2.0
103 * @return void
104 */
105 public function init(): void {
106 foreach (self::WATCHED_OPTIONS as $option) {
107 add_action("update_option_{$option}", [$this, 'flush_cache']);
108 add_action("add_option_{$option}", [$this, 'flush_cache']);
109 }
110
111 // Any ThinkRank settings category can feed a check (sitemap, schema,
112 // image SEO today; more later). Flushing on all of them is cheaper than
113 // a list that silently rots as checks are added.
114 add_action('thinkrank_seo_settings_saved', [$this, 'flush_cache']);
115 }
116
117 /**
118 * Return the cached analysis, computing (and caching) it when missing or
119 * when a fresh run is forced.
120 *
121 * @param bool $force When true, ignore and overwrite the cached result.
122 * @return array The analysis payload (see analyze()).
123 */
124 public function run(bool $force = false): array {
125 if (!$force) {
126 $cached = get_transient(self::CACHE_KEY);
127 if (is_array($cached) && isset($cached['overall_score'])) {
128 return $cached;
129 }
130 }
131
132 $result = $this->analyze();
133 set_transient(self::CACHE_KEY, $result, self::CACHE_TTL);
134
135 return $result;
136 }
137
138 /**
139 * Clear the cached analysis so the next run() recomputes.
140 *
141 * @return void
142 */
143 public function flush_cache(): void {
144 delete_transient(self::CACHE_KEY);
145 }
146
147 /**
148 * Run every registered check and aggregate the results.
149 *
150 * @return array {
151 * @type int $overall_score Weighted 0–100 site score.
152 * @type string $grade Letter grade A–F.
153 * @type array $summary passed/warning/failed/total counts.
154 * @type array $categories Per-category subtotal + its checks.
155 * @type array $checks Flat list of every check result.
156 * @type string $generated_at ISO-8601 UTC timestamp.
157 * }
158 */
159 public function analyze(): array {
160 $checks = $this->run_checks();
161 $category_labels = $this->get_category_labels();
162
163 $fraction = [
164 self::PASSED => 1.0,
165 self::WARNING => 0.5,
166 self::FAILED => 0.0,
167 ];
168
169 $total_weight = 0.0;
170 $earned = 0.0;
171 $summary = [self::PASSED => 0, self::WARNING => 0, self::FAILED => 0, 'total' => 0];
172 $categories = [];
173
174 foreach ($checks as $check) {
175 $weight = (float) $check['weight'];
176 $status = $check['status'];
177 $frac = $fraction[$status] ?? 0.0;
178
179 $total_weight += $weight;
180 $earned += $weight * $frac;
181
182 $summary[$status] = ($summary[$status] ?? 0) + 1;
183 $summary['total']++;
184
185 $cat = $check['category'];
186 if (!isset($categories[$cat])) {
187 $categories[$cat] = [
188 'id' => $cat,
189 'label' => $category_labels[$cat] ?? ucfirst($cat),
190 'score' => 0,
191 'weight' => 0.0,
192 'earned' => 0.0,
193 self::PASSED => 0,
194 self::WARNING => 0,
195 self::FAILED => 0,
196 'checks' => [],
197 ];
198 }
199 $categories[$cat]['weight'] += $weight;
200 $categories[$cat]['earned'] += $weight * $frac;
201 $categories[$cat][$status] = ($categories[$cat][$status] ?? 0) + 1;
202 $categories[$cat]['checks'][] = $check;
203 }
204
205 // Finalize per-category scores and drop the internal accumulators.
206 foreach ($categories as $cat => &$data) {
207 $data['score'] = $data['weight'] > 0
208 ? (int) round(($data['earned'] / $data['weight']) * 100)
209 : 0;
210 unset($data['weight'], $data['earned']);
211 }
212 unset($data);
213
214 $overall = $total_weight > 0 ? (int) round(($earned / $total_weight) * 100) : 0;
215
216 $result = [
217 'overall_score' => $overall,
218 'grade' => $this->score_to_grade($overall),
219 'summary' => $summary,
220 'categories' => array_values($categories),
221 'checks' => $checks,
222 'generated_at' => gmdate('c'),
223 ];
224
225 /**
226 * Fires after a site audit has been computed.
227 *
228 * Every path that produces a fresh analysis passes through here — the
229 * REST run route, the one-click fixer's re-run, and a cold cache — so a
230 * listener sees every run exactly once and never sees a cache hit.
231 * ThinkRank Pro uses this to persist a dated snapshot for the score
232 * trend and the run comparison.
233 *
234 * The payload is the analysis as returned to the caller; a listener
235 * must treat it as read-only.
236 *
237 * @since 2.5.0
238 *
239 * @param array $result The completed analysis (see the return docblock).
240 */
241 do_action('thinkrank_seo_analysis_completed', $result);
242
243 return $result;
244 }
245
246 /**
247 * Map a 0–100 score to a letter grade.
248 *
249 * @param int $score The overall score.
250 * @return string Letter grade.
251 */
252 private function score_to_grade(int $score): string {
253 if ($score >= 90) {
254 return 'A';
255 }
256 if ($score >= 80) {
257 return 'B';
258 }
259 if ($score >= 70) {
260 return 'C';
261 }
262 if ($score >= 60) {
263 return 'D';
264 }
265 return 'F';
266 }
267
268 /**
269 * Evaluate every registered check, normalizing each result.
270 *
271 * A check whose callback throws or returns a malformed value is skipped so
272 * one broken check can't take down the whole analysis.
273 *
274 * @return array<int,array> Normalized check results.
275 */
276 private function run_checks(): array {
277 $results = [];
278
279 foreach ($this->get_check_definitions() as $def) {
280 if (empty($def['callback']) || !is_callable($def['callback'])) {
281 continue;
282 }
283
284 try {
285 $outcome = call_user_func($def['callback']);
286 } catch (\Throwable $e) {
287 continue;
288 }
289
290 if (!is_array($outcome) || empty($outcome['status'])) {
291 continue;
292 }
293
294 $id = (string) ($def['id'] ?? '');
295 $status = (string) $outcome['status'];
296
297 // Only offer a fix on a finding that still needs one — a passing
298 // check with a Fix button reads as "did this even work?".
299 $fixable = self::PASSED !== $status && SEO_Analyzer_Fixer::can_fix($id);
300 $fix = $fixable ? (SEO_Analyzer_Fixer::fixable()[$id] ?? []) : [];
301
302 // The list can be capped below the number of items the finding is
303 // about, so the total travels with it.
304 $affected = $this->normalize_affected_posts($outcome['affected_posts'] ?? []);
305
306 $results[] = [
307 'id' => $id,
308 'category' => (string) ($def['category'] ?? 'basic'),
309 'weight' => isset($def['weight']) ? (float) $def['weight'] : 1.0,
310 'label' => (string) ($outcome['label'] ?? $def['label'] ?? ''),
311 'status' => $status,
312 'message' => (string) ($outcome['message'] ?? ''),
313 'how_to_fix' => (string) ($outcome['how_to_fix'] ?? ''),
314 'value' => $outcome['value'] ?? null,
315 'affected_posts' => $affected,
316 'affected_total' => max(count($affected), absint($outcome['affected_total'] ?? 0)),
317 'can_auto_fix' => $fixable,
318 'fix_label' => (string) ($fix['label'] ?? ''),
319 'fix_warning' => (string) ($fix['warning'] ?? ''),
320 ];
321 }
322
323 return $results;
324 }
325
326 /**
327 * Reduce a check's list of posts to fix to a known, escaped shape.
328 *
329 * The list reaches the audit UI as links, and a check registered through
330 * `thinkrank_seo_analyzer_checks` can put anything in it, so every entry is
331 * rebuilt here rather than passed through.
332 *
333 * @since 2.6.0
334 * @param mixed $posts Raw `affected_posts` from a check result.
335 * @return array<int,array{id: int, title: string, type: string, edit_url: string, url: string}>
336 */
337 private function normalize_affected_posts($posts): array {
338 if (!is_array($posts)) {
339 return [];
340 }
341
342 $out = [];
343
344 foreach ($posts as $post) {
345 if (!is_array($post) || empty($post['id'])) {
346 continue;
347 }
348
349 $out[] = [
350 'id' => absint($post['id']),
351 'title' => sanitize_text_field((string) ($post['title'] ?? '')),
352 'type' => sanitize_text_field((string) ($post['type'] ?? '')),
353 'edit_url' => esc_url_raw((string) ($post['edit_url'] ?? '')),
354 'url' => esc_url_raw((string) ($post['url'] ?? '')),
355 ];
356 }
357
358 return $out;
359 }
360
361 /**
362 * The registry of checks: id, category, weight, and the callback that
363 * evaluates it. Filterable so Pro/add-ons can register additional checks.
364 *
365 * @return array<int,array>
366 */
367 private function get_check_definitions(): array {
368 $definitions = [
369 // Basic SEO
370 ['id' => 'site_title', 'category' => 'basic', 'weight' => 2, 'callback' => [$this, 'check_site_title']],
371 ['id' => 'tagline', 'category' => 'basic', 'weight' => 1, 'callback' => [$this, 'check_tagline']],
372 ['id' => 'search_visibility', 'category' => 'basic', 'weight' => 3, 'callback' => [$this, 'check_search_visibility']],
373 ['id' => 'permalinks', 'category' => 'basic', 'weight' => 2, 'callback' => [$this, 'check_permalinks']],
374
375 // Advanced SEO
376 ['id' => 'xml_sitemap', 'category' => 'advanced', 'weight' => 2, 'callback' => [$this, 'check_sitemap']],
377 ['id' => 'schema', 'category' => 'advanced', 'weight' => 2, 'callback' => [$this, 'check_schema']],
378
379 // Content (bounded sample of published content)
380 ['id' => 'meta_descriptions', 'category' => 'content', 'weight' => 2, 'callback' => [$this, 'check_meta_descriptions']],
381 ['id' => 'image_alt_text', 'category' => 'content', 'weight' => 2, 'callback' => [$this, 'check_image_alt_text']],
382
383 // Performance & Technical
384 ['id' => 'php_version', 'category' => 'performance', 'weight' => 1, 'callback' => [$this, 'check_php_version']],
385 ['id' => 'object_cache', 'category' => 'performance', 'weight' => 1, 'callback' => [$this, 'check_object_cache']],
386
387 // Security
388 ['id' => 'https', 'category' => 'security', 'weight' => 3, 'callback' => [$this, 'check_https']],
389 ['id' => 'file_editing', 'category' => 'security', 'weight' => 2, 'callback' => [$this, 'check_file_editing']],
390 ['id' => 'debug_display', 'category' => 'security', 'weight' => 1, 'callback' => [$this, 'check_debug_display']],
391
392 // GEO / AEO — AI answer-engine readiness. The three configuration
393 // checks carry the weight of a normal check; the five sampled
394 // content ones are half that, so the category as a whole sits
395 // beside the existing ones rather than dominating the score.
396 ['id' => 'ai_crawler_access', 'category' => 'geo', 'weight' => 1.5, 'callback' => [$this, 'check_ai_crawler_access']],
397 ['id' => 'llms_txt', 'category' => 'geo', 'weight' => 1.5, 'callback' => [$this, 'check_llms_txt']],
398 ['id' => 'answer_ready_schema', 'category' => 'geo', 'weight' => 1.5, 'callback' => [$this, 'check_answer_ready_schema']],
399 ['id' => 'direct_answer', 'category' => 'geo', 'weight' => 0.5, 'callback' => [$this, 'check_direct_answer']],
400 ['id' => 'question_headings', 'category' => 'geo', 'weight' => 0.5, 'callback' => [$this, 'check_question_headings']],
401 ['id' => 'structured_content', 'category' => 'geo', 'weight' => 0.5, 'callback' => [$this, 'check_structured_content']],
402 ['id' => 'content_depth', 'category' => 'geo', 'weight' => 0.5, 'callback' => [$this, 'check_content_depth']],
403 ['id' => 'content_freshness', 'category' => 'geo', 'weight' => 0.5, 'callback' => [$this, 'check_content_freshness']],
404 ];
405
406 /**
407 * Filter the Site SEO Analyzer check registry.
408 *
409 * Each entry is an array with keys: id, category (basic|advanced|
410 * content|performance|security|geo), weight (float), and callback (callable
411 * returning ['status' => passed|warning|failed, 'label', 'message',
412 * 'how_to_fix']).
413 *
414 * @since 1.18.0
415 *
416 * @param array $definitions Registered checks.
417 * @param SEO_Analyzer $analyzer The analyzer instance.
418 */
419 $definitions = apply_filters('thinkrank_seo_analyzer_checks', $definitions, $this);
420
421 return is_array($definitions) ? $definitions : [];
422 }
423
424 // ─────────────────────────────────────────────────────────────────────
425 // Basic SEO checks
426 // ─────────────────────────────────────────────────────────────────────
427
428 /**
429 * The site must have a name/title configured.
430 *
431 * @return array
432 */
433 public function check_site_title(): array {
434 $title = trim((string) get_bloginfo('name'));
435
436 if ($title === '') {
437 return [
438 'label' => __('Site title is set', 'thinkrank'),
439 'status' => self::FAILED,
440 'message' => __('Your site has no title. Search engines and browsers use it as your brand name.', 'thinkrank'),
441 'how_to_fix' => __('Set a site title under Settings → General → Site Title.', 'thinkrank'),
442 ];
443 }
444
445 return [
446 'label' => __('Site title is set', 'thinkrank'),
447 'status' => self::PASSED,
448 'message' => __('Your site title is configured.', 'thinkrank'),
449 'value' => $title,
450 ];
451 }
452
453 /**
454 * The tagline should be set and not left at the WordPress default.
455 *
456 * @return array
457 */
458 public function check_tagline(): array {
459 $tagline = trim((string) get_bloginfo('description'));
460
461 $is_default = $this->is_default_tagline($tagline);
462
463 if ($tagline === '' || $is_default) {
464 return [
465 'label' => __('Tagline is customized', 'thinkrank'),
466 'status' => self::WARNING,
467 'message' => __('Your tagline is blank or still the WordPress default. Search engines may use it as your homepage description.', 'thinkrank'),
468 'how_to_fix' => __('Write a descriptive tagline under Settings → General → Tagline.', 'thinkrank'),
469 ];
470 }
471
472 return [
473 'label' => __('Tagline is customized', 'thinkrank'),
474 'status' => self::PASSED,
475 'message' => __('Your tagline is set and ready to describe your site.', 'thinkrank'),
476 'value' => $tagline,
477 ];
478 }
479
480 /**
481 * Whether a tagline is still WordPress' shipped default.
482 *
483 * The installer writes the TRANSLATED default into blogdescription, so an
484 * English-only literal silently passed an untouched tagline on every
485 * non-English install. The string lives in core's `admin-{locale}.mo`,
486 * which a REST request (how this analyzer runs) does not load — so the
487 * catalogue is loaded on demand for the comparison when the site is not
488 * running in English.
489 *
490 * @since 2.2.0
491 * @param string $tagline Trimmed tagline.
492 * @return bool
493 */
494 private function is_default_tagline(string $tagline): bool {
495 $candidates = ['Just another WordPress site'];
496
497 $locale = get_locale();
498 if ('en_US' !== $locale) {
499 // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch,WordPress.WP.I18n.LowLevelTranslationFunction -- core's own string in the `default` domain, read at runtime.
500 $translated = translate('Just another WordPress site', 'default');
501
502 if ($translated === 'Just another WordPress site') {
503 // Not in the loaded catalogue — pull in the admin one, which is
504 // where core ships this string, then ask again.
505 $mofile = WP_LANG_DIR . '/admin-' . $locale . '.mo';
506 if (is_readable($mofile)) {
507 load_textdomain('default', $mofile, $locale);
508 // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch,WordPress.WP.I18n.LowLevelTranslationFunction -- as above.
509 $translated = translate('Just another WordPress site', 'default');
510 }
511 }
512
513 $candidates[] = $translated;
514 }
515
516 foreach ($candidates as $candidate) {
517 if (strtolower($tagline) === strtolower($candidate)) {
518 return true;
519 }
520 }
521
522 return false;
523 }
524
525 /**
526 * "Discourage search engines from indexing this site" must be OFF.
527 *
528 * @return array
529 */
530 public function check_search_visibility(): array {
531 // blog_public = 0 means the WP "Discourage search engines" box is ticked.
532 if (!get_option('blog_public')) {
533 return [
534 'label' => __('Site is visible to search engines', 'thinkrank'),
535 'status' => self::FAILED,
536 'message' => __('Your site is telling search engines not to index it — it will not appear in search results.', 'thinkrank'),
537 'how_to_fix' => __('Untick "Discourage search engines from indexing this site" under Settings → Reading.', 'thinkrank'),
538 ];
539 }
540
541 return [
542 'label' => __('Site is visible to search engines', 'thinkrank'),
543 'status' => self::PASSED,
544 'message' => __('Your site allows search engines to index it.', 'thinkrank'),
545 ];
546 }
547
548 /**
549 * Permalinks should be pretty (not the default plain ?p=123 structure).
550 *
551 * @return array
552 */
553 public function check_permalinks(): array {
554 $structure = (string) get_option('permalink_structure');
555
556 if ($structure === '') {
557 return [
558 'label' => __('Search-friendly permalinks', 'thinkrank'),
559 'status' => self::WARNING,
560 'message' => __('Your site uses plain, numeric URLs (e.g. ?p=123). Descriptive URLs are easier for search engines and users.', 'thinkrank'),
561 'how_to_fix' => __('Choose a pretty permalink structure (e.g. Post name) under Settings → Permalinks.', 'thinkrank'),
562 ];
563 }
564
565 return [
566 'label' => __('Search-friendly permalinks', 'thinkrank'),
567 'status' => self::PASSED,
568 'message' => __('Your permalinks are search-friendly.', 'thinkrank'),
569 'value' => $structure,
570 ];
571 }
572
573 // ─────────────────────────────────────────────────────────────────────
574 // Advanced SEO checks
575 // ─────────────────────────────────────────────────────────────────────
576
577 /**
578 * The ThinkRank XML sitemap should be enabled.
579 *
580 * @return array
581 */
582 public function check_sitemap(): array {
583 $enabled = true;
584 try {
585 $generator = new Sitemap_Generator();
586 // 'site' is the stored context; 'global' is unsupported and
587 // returns DEFAULTS (enabled=true), which made this check unable
588 // to fail no matter what the user configured.
589 $data = $generator->get_output_data('site', null);
590 $enabled = !empty($data['enabled']);
591 } catch (\Throwable $e) {
592 // Fall back to "enabled" — the default state — on any lookup error.
593 $enabled = true;
594 }
595
596 if (!$enabled) {
597 return [
598 'label' => __('XML sitemap is enabled', 'thinkrank'),
599 'status' => self::WARNING,
600 'message' => __('Your XML sitemap is turned off. Search engines rely on it to discover new pages quickly.', 'thinkrank'),
601 'how_to_fix' => __('Enable the XML sitemap under Essential SEO → Crawling & AI Indexing → XML Sitemap.', 'thinkrank'),
602 ];
603 }
604
605 return [
606 'label' => __('XML sitemap is enabled', 'thinkrank'),
607 'status' => self::PASSED,
608 'message' => __('Your XML sitemap is enabled and pointing crawlers to your content.', 'thinkrank'),
609 ];
610 }
611
612 /**
613 * Structured data (schema) should be configured for at least one post type.
614 *
615 * @return array
616 */
617 public function check_schema(): array {
618 $label = __('Structured data configured', 'thinkrank');
619
620 if ($this->schema_is_configured()) {
621 return [
622 'label' => $label,
623 'status' => self::PASSED,
624 'message' => __('Structured data is configured for your content.', 'thinkrank'),
625 ];
626 }
627
628 // Nothing is configured, but ThinkRank still emits JSON-LD from its
629 // built-in per-post-type defaults. Saying "no schema" there would be
630 // false; the actionable point is that nobody has reviewed it.
631 if ($this->schema_is_output()) {
632 return [
633 'label' => $label,
634 'status' => self::WARNING,
635 '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'),
636 'how_to_fix' => __('Choose a schema type for each post type under Essential SEO → Bulk SEO Optimization.', 'thinkrank'),
637 ];
638 }
639
640 return [
641 'label' => $label,
642 'status' => self::FAILED,
643 'message' => __('No schema/structured data is configured or emitted. Schema powers rich results in search.', 'thinkrank'),
644 'how_to_fix' => __('Turn on automatic structured data, or choose a schema type for each post type under Essential SEO → Bulk SEO Optimization.', 'thinkrank'),
645 ];
646 }
647
648 /**
649 * Whether the user has EXPLICITLY configured structured data.
650 *
651 * Distinct from schema_is_output(): the Global SEO layer falls back to a
652 * built-in schema type for every public post type, so "something is
653 * emitted" is true on every site and made this check impossible to fail
654 * (its weight was earned unconditionally and its one-click fix was
655 * unreachable). This asks the question the check's copy actually claims to
656 * answer.
657 *
658 * Both layers must be read WITHOUT their defaults, or the same trap closes
659 * again one level down: get_settings() merges the context defaults under
660 * the saved rows, and Schema_Settings_Config's 'site' defaults set both
661 * enabled_schema_types and auto_generate_schema — so an untouched site came
662 * back looking configured and this method still could not return false
663 * (#586). get_stored_settings() answers with only what was actually saved.
664 *
665 * @since 2.2.0
666 * @return bool
667 */
668 private function schema_is_configured(): bool {
669 // 1) Schema Management System — an explicit opt-in. Read the SAVED rows
670 // only; the defaults-merged view is truthy on every site.
671 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
672 $settings = (new Schema_Management_System())->get_stored_settings('site', null);
673 // The master switch gates these the same way it gates
674 // schema_is_output(). Saving the settings form persists the whole
675 // payload, so turning the feature off stores enabled = '0' while
676 // auto_generate_schema stays '1' — and reading past the switch then
677 // reported "structured data is configured" for a site emitting
678 // none, with the one-click fix withheld. array_key_exists rather
679 // than a bare !empty so an untouched site, where 'enabled' was
680 // never saved at all, still falls through to the Global SEO layer
681 // below instead of short-circuiting to false.
682 $master_on = is_array($settings)
683 && (!array_key_exists('enabled', $settings) || !empty($settings['enabled']));
684
685 if ($master_on) {
686 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
687 return true;
688 }
689 if (!empty($settings['auto_generate_schema'])) {
690 return true;
691 }
692 }
693 }
694
695 // 2) A saved per-post-type schema_type in the Global SEO layer. The
696 // built-in default deliberately does not count here.
697 if (class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
698 $output = new \ThinkRank\Frontend\Global_SEO_Schema_Output();
699 foreach (get_post_types(['public' => true], 'names') as $post_type) {
700 if ($output->has_explicit_schema_type((string) $post_type)) {
701 return true;
702 }
703 }
704 }
705
706 return false;
707 }
708
709 /**
710 * Whether ThinkRank actually emits structured data for this site.
711 *
712 * The audit must reflect what is rendered, not a single legacy option.
713 * ThinkRank outputs schema from two current sources, so this check consults
714 * both rather than the deprecated thinkrank_global_seo_settings['schema_type']
715 * opt-in (which most sites never set even though schema is emitted):
716 *
717 * 1. The Schema Management System — its configuration lives in the
718 * thinkrank_seo_settings table (context "schema_management_system"),
719 * read through the manager's settings abstraction.
720 * 2. The Global SEO output layer — an explicit saved schema_type OR the
721 * built-in per-post-type default both cause JSON-LD to be emitted on
722 * the frontend. We ask that layer directly (would_output_schema) so the
723 * audit and the rendered page can never diverge.
724 *
725 * @return bool True when structured data is emitted for the site's content.
726 */
727 private function schema_is_output(): bool {
728 // 1) Newer Schema Management System (thinkrank_seo_settings table).
729 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
730 // 'site' is the context type; 'schema_management_system' is the manager
731 // NAME, which get_settings() rejects as an unsupported context and
732 // answers with bare defaults — where auto_generate_schema is true, so
733 // this always returned true and never read the site's real settings (#473).
734 //
735 // This one KEEPS the defaults-merged view on purpose, unlike
736 // schema_is_configured() (#586). The question here is "does JSON-LD
737 // reach the page?", and an untouched site answers yes: the 'site'
738 // defaults leave the system enabled with auto_generate_schema on, so
739 // the merged value is the emitted behaviour, not a mask over it.
740 $settings = (new Schema_Management_System())->get_settings('site', null);
741 // The master switch gates everything below it: with 'enabled' off,
742 // get_output_data() reports the feature as off and nothing is
743 // emitted, so reading auto_generate_schema past it told the audit
744 // schema was on the page when it was not (the same shape as #461).
745 if (is_array($settings) && !empty($settings['enabled'])) {
746 if (!empty($settings['enabled_schema_types']) && is_array($settings['enabled_schema_types'])) {
747 return true;
748 }
749 if (!empty($settings['auto_generate_schema'])) {
750 return true;
751 }
752 }
753 }
754
755 // 2) Global SEO output layer — explicit schema_type or per-post-type
756 // default. Reuse the output layer's own decision so audit == output.
757 if (class_exists('ThinkRank\\Frontend\\Global_SEO_Schema_Output')) {
758 $output = new \ThinkRank\Frontend\Global_SEO_Schema_Output();
759 foreach (get_post_types(['public' => true], 'names') as $post_type) {
760 if ($output->would_output_schema((string) $post_type)) {
761 return true;
762 }
763 }
764 }
765
766 return false;
767 }
768
769 // ─────────────────────────────────────────────────────────────────────
770 // Content checks (bounded sample of published content)
771 // ─────────────────────────────────────────────────────────────────────
772
773 /**
774 * How many recent published posts/pages the content checks sample.
775 */
776 private const CONTENT_SAMPLE_SIZE = 100;
777
778 /**
779 * Coverage thresholds shared by the content checks: at or above the first
780 * is a pass, at or above the second is a warning, below it a fail.
781 */
782 private const COVERAGE_PASS = 90;
783 private const COVERAGE_WARN = 50;
784
785 /**
786 * Most items a finding names when it is not drawn from the bounded sample.
787 *
788 * The image check counts the whole media library, which can run to tens of
789 * thousands of rows; a list that long would bloat the cached analysis and
790 * every stored audit snapshot while telling the user nothing more.
791 */
792 private const AFFECTED_LIMIT = 100;
793
794 /**
795 * Recent published posts/pages should have meta descriptions.
796 *
797 * Samples the most recent CONTENT_SAMPLE_SIZE published posts/pages so the
798 * check stays fast on large sites.
799 *
800 * @return array
801 */
802 public function check_meta_descriptions(): array {
803 $label = __('Posts have meta descriptions', 'thinkrank');
804
805 $post_ids = $this->sample_post_ids();
806
807 $total = count($post_ids);
808 if (0 === $total) {
809 return [
810 'label' => $label,
811 'status' => self::PASSED,
812 'message' => __('No published content to check yet.', 'thinkrank'),
813 ];
814 }
815
816 // Count posts with an *effective* meta description, the same way the
817 // frontend resolves it: a custom _thinkrank_meta_description when set,
818 // otherwise the global SEO pattern fallback (Pattern_Resolver). Counting
819 // only the custom post-meta produced false negatives — posts that output
820 // a valid description via the pattern fallback were wrongly reported as
821 // missing. The sample is bounded (CONTENT_SAMPLE_SIZE) so the per-post
822 // resolution stays cheap, and the whole analysis is cached for an hour.
823 // 'fields' => 'ids' skips WP_Query's meta priming, so the first
824 // get_post_meta() below would issue a query per post. Warm the whole
825 // sample once instead — 100 posts went from ~200 queries to a handful.
826 _prime_post_caches($post_ids, false, true);
827
828 $with_description = 0;
829 $without = [];
830 foreach ($post_ids as $post_id) {
831 $custom = (string) get_post_meta($post_id, '_thinkrank_meta_description', true);
832 $resolved = '' !== $custom ? $custom : Pattern_Resolver::description((int) $post_id);
833 if ('' !== trim($resolved)) {
834 $with_description++;
835 } else {
836 $without[] = (int) $post_id;
837 }
838 }
839
840 $coverage = (int) round(($with_description / $total) * 100);
841 $missing = $total - $with_description;
842 $value = sprintf('%d/%d', $with_description, $total);
843
844 if ($coverage >= self::COVERAGE_PASS) {
845 return [
846 'label' => $label,
847 'status' => self::PASSED,
848 /* translators: 1: posts with meta description, 2: sampled posts. */
849 'message' => sprintf(__('%1$d of your %2$d most recent posts have a meta description.', 'thinkrank'), $with_description, $total),
850 'value' => $value,
851 ];
852 }
853
854 return [
855 'label' => $label,
856 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
857 /* translators: 1: posts missing a meta description, 2: sampled posts. */
858 '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),
859 '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'),
860 'value' => $value,
861 'affected_posts' => $this->affected_posts($without),
862 ];
863 }
864
865 /**
866 * Uploaded images should have alt text — it is an accessibility
867 * requirement and how image search understands your media.
868 *
869 * @return array
870 */
871 public function check_image_alt_text(): array {
872 $label = __('Images have alt text', 'thinkrank');
873
874 global $wpdb;
875 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- two indexed COUNTs; results are cached at the analysis level
876 $total = (int) $wpdb->get_var(
877 "SELECT COUNT(*) FROM {$wpdb->posts}
878 WHERE post_type = 'attachment'
879 AND post_mime_type LIKE 'image/%'
880 AND post_status != 'trash'"
881 );
882
883 if (0 === $total) {
884 return [
885 'label' => $label,
886 'status' => self::PASSED,
887 'message' => __('No images in your media library to check yet.', 'thinkrank'),
888 ];
889 }
890
891 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- indexed COUNT via postmeta meta_key index
892 $with_alt = (int) $wpdb->get_var(
893 "SELECT COUNT(DISTINCT p.ID) FROM {$wpdb->posts} p
894 INNER JOIN {$wpdb->postmeta} pm
895 ON pm.post_id = p.ID
896 AND pm.meta_key = '_wp_attachment_image_alt'
897 AND pm.meta_value != ''
898 WHERE p.post_type = 'attachment'
899 AND p.post_mime_type LIKE 'image/%'
900 AND p.post_status != 'trash'"
901 );
902
903 $coverage = (int) round(($with_alt / $total) * 100);
904 $missing = $total - $with_alt;
905 $value = sprintf('%d/%d', $with_alt, $total);
906
907 if ($coverage >= self::COVERAGE_PASS) {
908 return [
909 'label' => $label,
910 'status' => self::PASSED,
911 /* translators: 1: images with alt text, 2: total images. */
912 'message' => sprintf(__('%1$d of your %2$d images have alt text.', 'thinkrank'), $with_alt, $total),
913 'value' => $value,
914 ];
915 }
916
917 return [
918 'label' => $label,
919 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
920 /* translators: 1: images missing alt text, 2: total images. */
921 '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),
922 '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'),
923 'value' => $value,
924 'affected_posts' => $this->affected_posts($this->image_ids_without_alt()),
925 'affected_total' => $missing,
926 ];
927 }
928
929 /**
930 * The most recent images with no alt text, capped at AFFECTED_LIMIT.
931 *
932 * Mirrors the counts above: an image counts as having alt text when any
933 * `_wp_attachment_image_alt` row for it is non-empty.
934 *
935 * @since 2.6.0
936 * @return int[]
937 */
938 private function image_ids_without_alt(): array {
939 global $wpdb;
940
941 // 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
942 $ids = $wpdb->get_col(
943 $wpdb->prepare(
944 "SELECT p.ID FROM {$wpdb->posts} p
945 WHERE p.post_type = 'attachment'
946 AND p.post_mime_type LIKE %s
947 AND p.post_status != 'trash'
948 AND NOT EXISTS (
949 SELECT 1 FROM {$wpdb->postmeta} pm
950 WHERE pm.post_id = p.ID
951 AND pm.meta_key = '_wp_attachment_image_alt'
952 AND pm.meta_value != ''
953 )
954 ORDER BY p.post_date DESC
955 LIMIT %d",
956 $wpdb->esc_like('image/') . '%',
957 self::AFFECTED_LIMIT
958 )
959 );
960
961 return array_map('intval', (array) $ids);
962 }
963
964 // ─────────────────────────────────────────────────────────────────────
965 // Performance & Technical checks
966 // ─────────────────────────────────────────────────────────────────────
967
968 /**
969 * The site should run a supported PHP version.
970 *
971 * @return array
972 */
973 public function check_php_version(): array {
974 $current = PHP_VERSION;
975 // ThinkRank itself requires PHP 8.0 to run, so anything below 8.1 (the
976 // oldest actively-supported branch) is the meaningful warning line —
977 // a 7.x threshold here could never fire.
978 $supported = version_compare($current, '8.1', '>=');
979
980 if (!$supported) {
981 return [
982 'label' => __('Supported PHP version', 'thinkrank'),
983 'status' => self::WARNING,
984 /* translators: %s: current PHP version. */
985 'message' => sprintf(__('You are running PHP %s, which no longer receives active support. Newer PHP is faster and more secure.', 'thinkrank'), $current),
986 'how_to_fix' => __('Ask your host to upgrade to PHP 8.1 or newer.', 'thinkrank'),
987 'value' => $current,
988 ];
989 }
990
991 return [
992 'label' => __('Supported PHP version', 'thinkrank'),
993 'status' => self::PASSED,
994 /* translators: %s: current PHP version. */
995 'message' => sprintf(__('You are running a supported PHP version (%s).', 'thinkrank'), $current),
996 'value' => $current,
997 ];
998 }
999
1000 /**
1001 * A persistent object cache should be active for a faster, less DB-bound
1002 * site.
1003 *
1004 * @return array
1005 */
1006 public function check_object_cache(): array {
1007 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
1008 return [
1009 'label' => __('Persistent object cache', 'thinkrank'),
1010 'status' => self::PASSED,
1011 'message' => __('A persistent object cache is active, reducing database load.', 'thinkrank'),
1012 ];
1013 }
1014
1015 return [
1016 'label' => __('Persistent object cache', 'thinkrank'),
1017 'status' => self::WARNING,
1018 'message' => __('No persistent object cache is active. On busier sites this means more database queries per request.', 'thinkrank'),
1019 'how_to_fix' => __('Enable a persistent object cache (e.g. Redis or Memcached) via your host or a caching plugin.', 'thinkrank'),
1020 ];
1021 }
1022
1023 // ─────────────────────────────────────────────────────────────────────
1024 // Security checks
1025 // ─────────────────────────────────────────────────────────────────────
1026
1027 /**
1028 * The site should be served over HTTPS (SSL).
1029 *
1030 * @return array
1031 */
1032 public function check_https(): array {
1033 $home = (string) get_option('home');
1034 $uses_https = strpos($home, 'https://') === 0;
1035
1036 // WP 5.7+ can tell us the site is fully HTTPS-capable.
1037 if (function_exists('wp_is_using_https')) {
1038 $uses_https = $uses_https && wp_is_using_https();
1039 }
1040
1041 if (!$uses_https) {
1042 return [
1043 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
1044 'status' => self::FAILED,
1045 'message' => __('Your site URL is not served over HTTPS. HTTPS is a confirmed ranking signal and required for user trust.', 'thinkrank'),
1046 'how_to_fix' => __('Install an SSL certificate and set your WordPress Address / Site Address to https:// under Settings → General.', 'thinkrank'),
1047 ];
1048 }
1049
1050 return [
1051 'label' => __('Site uses HTTPS (SSL)', 'thinkrank'),
1052 'status' => self::PASSED,
1053 'message' => __('Your site is served securely over HTTPS.', 'thinkrank'),
1054 ];
1055 }
1056
1057 /**
1058 * The built-in plugin/theme file editor should be disabled
1059 * (DISALLOW_FILE_EDIT) so a compromised admin cannot edit PHP from wp-admin.
1060 *
1061 * @return array
1062 */
1063 public function check_file_editing(): array {
1064 if (defined('DISALLOW_FILE_EDIT') && DISALLOW_FILE_EDIT) {
1065 return [
1066 'label' => __('File editing disabled', 'thinkrank'),
1067 'status' => self::PASSED,
1068 'message' => __('The dashboard plugin/theme file editor is disabled, reducing your attack surface.', 'thinkrank'),
1069 ];
1070 }
1071
1072 return [
1073 'label' => __('File editing disabled', 'thinkrank'),
1074 'status' => self::WARNING,
1075 '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'),
1076 'how_to_fix' => __('Add define(\'DISALLOW_FILE_EDIT\', true); to your wp-config.php.', 'thinkrank'),
1077 ];
1078 }
1079
1080 /**
1081 * The site should not publicly display PHP errors (WP_DEBUG_DISPLAY),
1082 * which can leak server paths and internals.
1083 *
1084 * @return array
1085 */
1086 public function check_debug_display(): array {
1087 $debug = defined('WP_DEBUG') && WP_DEBUG;
1088 // WP_DEBUG_DISPLAY only shows errors when it is on (its default) AND
1089 // WP_DEBUG is enabled.
1090 $display = !defined('WP_DEBUG_DISPLAY') || WP_DEBUG_DISPLAY;
1091 $exposing = $debug && $display;
1092
1093 if ($exposing) {
1094 return [
1095 'label' => __('Errors not shown publicly', 'thinkrank'),
1096 'status' => self::WARNING,
1097 'message' => __('Debug output is displayed on the front end. Visible PHP errors can leak server paths and internals.', 'thinkrank'),
1098 'how_to_fix' => __('Set define(\'WP_DEBUG_DISPLAY\', false); (or turn off WP_DEBUG) in wp-config.php on production.', 'thinkrank'),
1099 ];
1100 }
1101
1102 return [
1103 'label' => __('Errors not shown publicly', 'thinkrank'),
1104 'status' => self::PASSED,
1105 'message' => __('PHP errors are not displayed to visitors.', 'thinkrank'),
1106 ];
1107 }
1108 // ─────────────────────────────────────────────────────────────────────
1109 // GEO / AEO checks (AI answer-engine readiness)
1110 // ─────────────────────────────────────────────────────────────────────
1111
1112 /**
1113 * The crawlers that decide whether a site can be CITED by an AI answer
1114 * engine, as opposed to the ones that only collect training data.
1115 *
1116 * Blocking a training crawler (GPTBot, ClaudeBot, CCBot…) is a legitimate
1117 * editorial choice and is deliberately not marked down here: it costs the
1118 * site nothing in ChatGPT, Claude, Perplexity or AI Overviews. Blocking
1119 * the agents below is what makes a site invisible to those answers, so
1120 * they are the only ones this check reports on.
1121 *
1122 * Public because the one-click fix writes exactly this set to `allow`;
1123 * a second copy in the fixer is how the check and its remedy drift into
1124 * disagreeing about which crawlers matter.
1125 *
1126 * @since 2.5.0
1127 * @var string[]
1128 */
1129 public const GEO_ANSWER_AGENTS = [
1130 'oai-searchbot',
1131 'chatgpt-user',
1132 'perplexitybot',
1133 'perplexity-user',
1134 'claude-searchbot',
1135 'claude-user',
1136 'google-extended',
1137 'mistral-user',
1138 ];
1139
1140 /**
1141 * Word-count window for an opening passage that reads as a direct answer.
1142 *
1143 * The AEO convention is a self-contained 40–60 word answer directly under
1144 * the title. The window is widened at both ends so ordinary good writing
1145 * passes: below the floor there is no answer to quote, and well above the
1146 * ceiling the passage is a preamble an engine has to summarize rather than
1147 * a sentence it can lift.
1148 */
1149 private const GEO_ANSWER_MIN_WORDS = 20;
1150 private const GEO_ANSWER_MAX_WORDS = 120;
1151
1152 /**
1153 * Words below which a page has too little substance to be cited.
1154 */
1155 private const GEO_DEPTH_MIN_WORDS = 300;
1156
1157 /**
1158 * How long a page can go unrevised before it reads as stale to an engine
1159 * that prefers recent sources.
1160 */
1161 private const GEO_FRESHNESS_MAX_AGE = 365 * DAY_IN_SECONDS;
1162
1163 /**
1164 * The content sample the GEO checks share, memoized for one analysis.
1165 *
1166 * Five checks read the same 100 posts. Sampling once turns five queries
1167 * (and five cache-priming passes) into one, and guarantees the five
1168 * results describe the same set of posts.
1169 *
1170 * @since 2.5.0
1171 * @var array<int,array{id: int, content: string, text: string, modified: int}>|null
1172 */
1173 private $content_sample = null;
1174
1175 /**
1176 * The sampled post ids, memoized so the Content and GEO categories run one
1177 * query between them rather than one each over the same CONTENT_SAMPLE_SIZE
1178 * posts. Both want the same slice — the most recent published posts and
1179 * pages — so two queries only guaranteed they could disagree after a
1180 * publish mid-analysis.
1181 *
1182 * @since 2.5.0
1183 * @var int[]|null
1184 */
1185 private $sample_post_ids = null;
1186
1187 /**
1188 * The sampled post ids, fetched once and shared by every check that reads
1189 * the same slice.
1190 *
1191 * @since 2.5.0
1192 * @return int[]
1193 */
1194 private function sample_post_ids(): array {
1195 if (null !== $this->sample_post_ids) {
1196 return $this->sample_post_ids;
1197 }
1198
1199 $this->sample_post_ids = get_posts([
1200 'post_type' => ['post', 'page'],
1201 'post_status' => 'publish',
1202 'posts_per_page' => self::CONTENT_SAMPLE_SIZE,
1203 'orderby' => 'date',
1204 'order' => 'DESC',
1205 'fields' => 'ids',
1206 'no_found_rows' => true,
1207 'suppress_filters' => false,
1208 ]);
1209
1210 return $this->sample_post_ids;
1211 }
1212
1213 /**
1214 * The most recent published posts/pages, as raw content, its plain-text
1215 * rendering and the modified time.
1216 *
1217 * Raw `post_content` on purpose: running `the_content` over 100 posts in a
1218 * REST request would fire every shortcode and block renderer on the site.
1219 * The structural signals these checks look for (headings, lists, tables,
1220 * the opening passage) survive in the stored markup.
1221 *
1222 * @since 2.5.0
1223 * @return array<int,array{id: int, content: string, text: string, modified: int}>
1224 */
1225 private function get_content_sample(): array {
1226 if (null !== $this->content_sample) {
1227 return $this->content_sample;
1228 }
1229
1230 $post_ids = $this->sample_post_ids();
1231
1232 if (function_exists('_prime_post_caches')) {
1233 _prime_post_caches($post_ids, false, false);
1234 }
1235
1236 $sample = [];
1237
1238 foreach ($post_ids as $post_id) {
1239 $post = get_post($post_id);
1240 if (!$post) {
1241 continue;
1242 }
1243
1244 $modified = isset($post->post_modified_gmt) ? strtotime((string) $post->post_modified_gmt . ' UTC') : false;
1245
1246 $content = (string) $post->post_content;
1247
1248 $sample[] = [
1249 'id' => (int) $post->ID,
1250 'content' => $content,
1251 'text' => $this->content_to_text($content),
1252 'modified' => is_int($modified) ? $modified : 0,
1253 ];
1254 }
1255
1256 $this->content_sample = $sample;
1257
1258 return $sample;
1259 }
1260
1261 /**
1262 * Shared shape for the content-sampled GEO checks: count how many posts in
1263 * the sample satisfy a predicate and grade it on the coverage thresholds
1264 * the Content category already uses.
1265 *
1266 * @since 2.5.0
1267 *
1268 * @param string $label Check label.
1269 * @param callable $predicate Receives one sample row, returns bool.
1270 * @param string $pass_text sprintf template: 1 = matching, 2 = sampled.
1271 * @param string $fail_text sprintf template: 1 = missing, 2 = sampled.
1272 * @param string $how_to_fix Advice shown on a warning/fail.
1273 * @param string $empty_text Message when the site has no content yet.
1274 * @return array
1275 */
1276 private function coverage_check(
1277 string $label,
1278 callable $predicate,
1279 string $pass_text,
1280 string $fail_text,
1281 string $how_to_fix,
1282 string $empty_text
1283 ): array {
1284 // A page whose body is a shortcode or a builder layout leaves no
1285 // extractable text, so every prose-shaped question here answers "no"
1286 // for it — Cart, Checkout, My account and Shop would drag the category
1287 // down over content nobody wants quoted in an AI answer. Skipping them
1288 // is deliberate: this grades the pages that could be cited.
1289 $sample = [];
1290 foreach ($this->get_content_sample() as $row) {
1291 if ('' !== $row['text']) {
1292 $sample[] = $row;
1293 }
1294 }
1295
1296 $total = count($sample);
1297
1298 if (0 === $total) {
1299 return [
1300 'label' => $label,
1301 'status' => self::PASSED,
1302 'message' => $empty_text,
1303 ];
1304 }
1305
1306 $matching = 0;
1307 $failing = [];
1308 foreach ($sample as $row) {
1309 if ($predicate($row)) {
1310 $matching++;
1311 } else {
1312 $failing[] = $row['id'];
1313 }
1314 }
1315
1316 $coverage = (int) round(($matching / $total) * 100);
1317 $value = sprintf('%d/%d', $matching, $total);
1318
1319 if ($coverage >= self::COVERAGE_PASS) {
1320 return [
1321 'label' => $label,
1322 'status' => self::PASSED,
1323 'message' => sprintf($pass_text, $matching, $total),
1324 'value' => $value,
1325 ];
1326 }
1327
1328 // A count alone leaves the user to guess which pages it means, so the
1329 // finding names them.
1330 return [
1331 'label' => $label,
1332 'status' => $coverage >= self::COVERAGE_WARN ? self::WARNING : self::FAILED,
1333 'message' => sprintf($fail_text, $total - $matching, $total),
1334 'how_to_fix' => $how_to_fix,
1335 'value' => $value,
1336 'affected_posts' => $this->affected_posts($failing),
1337 ];
1338 }
1339
1340 /**
1341 * The posts a finding is about, with where to fix and where to view each.
1342 *
1343 * The edit link is built rather than taken from get_edit_post_link(), which
1344 * returns nothing without a user who can edit — and the analysis is cached
1345 * and can be computed outside a request.
1346 *
1347 * @since 2.6.0
1348 * @param int[] $post_ids Post ids, in sample order.
1349 * @return array<int,array{id: int, title: string, type: string, edit_url: string, url: string}>
1350 */
1351 private function affected_posts(array $post_ids): array {
1352 $posts = [];
1353
1354 if ($post_ids && function_exists('_prime_post_caches')) {
1355 _prime_post_caches($post_ids, false, false);
1356 }
1357
1358 foreach ($post_ids as $post_id) {
1359 $post = get_post($post_id);
1360 if (!$post) {
1361 continue;
1362 }
1363
1364 $type_object = get_post_type_object($post->post_type);
1365 $title = html_entity_decode((string) get_the_title($post), ENT_QUOTES, 'UTF-8');
1366
1367 // An attachment's permalink is its attachment page, which core
1368 // redirects to the file on most sites; link the file itself.
1369 $url = 'attachment' === $post->post_type && function_exists('wp_get_attachment_url')
1370 ? (string) wp_get_attachment_url((int) $post->ID)
1371 : (string) get_permalink($post);
1372
1373 $posts[] = [
1374 'id' => (int) $post->ID,
1375 'title' => '' !== trim($title) ? $title : __('(no title)', 'thinkrank'),
1376 'type' => $type_object ? (string) $type_object->labels->singular_name : (string) $post->post_type,
1377 'edit_url' => admin_url('post.php?post=' . (int) $post->ID . '&action=edit'),
1378 'url' => $url,
1379 ];
1380 }
1381
1382 return $posts;
1383 }
1384
1385 /**
1386 * The plain text of a post's content, with markup, blocks and shortcodes
1387 * reduced to the words a language model would actually read.
1388 *
1389 * @since 2.5.0
1390 * @param string $content Raw post content.
1391 * @return string
1392 */
1393 private function content_to_text(string $content): string {
1394 // Block delimiters are HTML comments, so strip_tags leaves their
1395 // attribute JSON behind as text and inflates every word count.
1396 $text = preg_replace('/<!--.*?-->/s', ' ', $content);
1397 $text = preg_replace('/\[[^\]]*\]/', ' ', (string) $text);
1398 $text = wp_strip_all_tags((string) $text);
1399
1400 // `/u` makes preg_replace() return NULL on content that is not valid
1401 // UTF-8 — a latin1 install, a raw SQL import, a migrated dump — and
1402 // trim(null) is a TypeError under strict_types. run_checks() catches
1403 // Throwable and continues, so the check did not fail: it DISAPPEARED
1404 // from the audit, taking its weight with it and pushing the overall
1405 // score UP because a failing check had been removed rather than scored.
1406 // Fall back to the byte-wise collapse when the Unicode pass cannot read
1407 // the bytes; a slightly coarser word split is worth far more than a
1408 // check that silently deletes itself.
1409 $collapsed = preg_replace('/\s+/u', ' ', (string) $text);
1410
1411 if (null === $collapsed) {
1412 $collapsed = preg_replace('/\s+/', ' ', (string) $text);
1413 }
1414
1415 return trim((string) $collapsed);
1416 }
1417
1418 /**
1419 * Word count of a string of plain text.
1420 *
1421 * @since 2.5.0
1422 * @param string $text Plain text.
1423 * @return int
1424 */
1425 private function word_count(string $text): int {
1426 if ('' === $text) {
1427 return 0;
1428 }
1429
1430 return count(preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: []);
1431 }
1432
1433 /**
1434 * Whether AI answer engines are allowed to reach this site.
1435 *
1436 * Reads the robots.txt that is ACTUALLY served — physical file, custom
1437 * body or generated defaults — rather than the per-agent rule map alone,
1438 * because a hand-written `Disallow: /` for GPTBot blocks it just as
1439 * effectively as the toggle does, and a `User-agent: *` block reaches
1440 * every crawler on the list at once.
1441 *
1442 * @since 2.5.0
1443 * @return array
1444 */
1445 public function check_ai_crawler_access(): array {
1446 $label = __('AI answer engines can crawl your site', 'thinkrank');
1447 $blocked = $this->blocked_answer_agents();
1448
1449 if (empty($blocked)) {
1450 return [
1451 'label' => $label,
1452 'status' => self::PASSED,
1453 'message' => __('ChatGPT, Claude, Perplexity and Google AI Overviews can all reach your content and cite it.', 'thinkrank'),
1454 ];
1455 }
1456
1457 $names = implode(', ', $blocked);
1458 // Every one of them blocked is a different situation from one stray
1459 // rule: the site cannot appear in AI answers at all. Counted against
1460 // the agents the registry actually knows, not the slug list: a slug
1461 // leaving AI_Crawlers can never be collected as blocked, so comparing
1462 // with the list would make this branch quietly unreachable.
1463 $known = count($this->known_answer_agents());
1464 $all = $known > 0 && count($blocked) >= $known;
1465
1466 return [
1467 'label' => $label,
1468 'status' => $all ? self::FAILED : self::WARNING,
1469 'message' => $all
1470 /* translators: %s: comma-separated crawler names. */
1471 ? sprintf(__('Your robots.txt blocks every AI answer engine (%s), so your pages cannot be cited in AI answers at all.', 'thinkrank'), $names)
1472 /* translators: %s: comma-separated crawler names. */
1473 : sprintf(__('Your robots.txt blocks these AI answer engines: %s. Their assistants cannot read or cite your pages.', 'thinkrank'), $names),
1474 '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'),
1475 'value' => $names,
1476 ];
1477 }
1478
1479 /**
1480 * The answer-engine crawlers the served robots.txt disallows entirely.
1481 *
1482 * Public so the one-click fix can re-ask the question after writing the
1483 * rules, rather than reporting a success the served file contradicts.
1484 *
1485 * @since 2.5.0
1486 * @return string[] Crawler labels, empty when all are allowed.
1487 */
1488 public function blocked_answer_agents(): array {
1489 if (!class_exists('ThinkRank\\SEO\\Site_Identity_Manager') || !class_exists('ThinkRank\\SEO\\AI_Crawlers')) {
1490 return [];
1491 }
1492
1493 $effective = (new Site_Identity_Manager())->get_effective_robots_txt();
1494 $groups = $this->parse_robots_disallow_all((string) ($effective['content'] ?? ''));
1495
1496 if (empty($groups)) {
1497 return [];
1498 }
1499
1500 $blocked = [];
1501
1502 foreach ($this->known_answer_agents() as $agent) {
1503 $token = strtolower((string) $agent['token']);
1504 // A group naming the agent wins over the wildcard group, which is
1505 // how robots.txt precedence works: the most specific matching
1506 // group is the only one that applies.
1507 $denied = array_key_exists($token, $groups) ? $groups[$token] : ($groups['*'] ?? false);
1508
1509 if ($denied) {
1510 $blocked[] = $agent['label'];
1511 }
1512 }
1513
1514 return $blocked;
1515 }
1516
1517 /**
1518 * The answer-engine crawlers the AI_Crawlers registry actually knows.
1519 *
1520 * The slug list is ThinkRank's editorial position on which crawlers decide
1521 * whether a site can be cited; the registry is what carries their tokens
1522 * and labels. Everything that counts answer engines counts these, so a slug
1523 * that ever leaves the registry drops out of the check and its totals
1524 * together instead of skewing one against the other.
1525 *
1526 * @since 2.5.0
1527 * @return array<string,array> Registry entries keyed by slug.
1528 */
1529 private function known_answer_agents(): array {
1530 if (!class_exists('ThinkRank\\SEO\\AI_Crawlers')) {
1531 return [];
1532 }
1533
1534 $agents = AI_Crawlers::all();
1535 $known = [];
1536
1537 foreach (self::GEO_ANSWER_AGENTS as $slug) {
1538 if (isset($agents[$slug]['token'], $agents[$slug]['label'])) {
1539 $known[$slug] = $agents[$slug];
1540 }
1541 }
1542
1543 return $known;
1544 }
1545
1546 /**
1547 * Map a robots.txt body to `user-agent => disallows everything`.
1548 *
1549 * Consecutive `User-agent:` lines open one shared group, so the agents
1550 * listed above a `Disallow: /` all inherit it. An agent that appears with
1551 * narrower rules is recorded as false rather than omitted — otherwise it
1552 * would fall through to the wildcard group it is meant to override.
1553 *
1554 * @since 2.5.0
1555 * @param string $body Robots.txt content.
1556 * @return array<string,bool>
1557 */
1558 private function parse_robots_disallow_all(string $body): array {
1559 $groups = [];
1560 $current = [];
1561 // Whether the next `User-agent:` continues this group or opens a new
1562 // one. Rules close a group; another agent line before any rule does not.
1563 $collecting = true;
1564
1565 foreach (preg_split('/\R/', $body) ?: [] as $line) {
1566 $line = trim((string) preg_replace('/#.*/', '', $line));
1567
1568 if ('' === $line || false === strpos($line, ':')) {
1569 continue;
1570 }
1571
1572 [$field, $value] = array_map('trim', explode(':', $line, 2));
1573 $field = strtolower($field);
1574
1575 if ('user-agent' === $field) {
1576 if (!$collecting) {
1577 $current = [];
1578 $collecting = true;
1579 }
1580
1581 $agent = strtolower($value);
1582 if ('' !== $agent) {
1583 $current[] = $agent;
1584 if (!isset($groups[$agent])) {
1585 $groups[$agent] = false;
1586 }
1587 }
1588
1589 continue;
1590 }
1591
1592 $collecting = false;
1593
1594 // `/` is the canonical full block; `/*` is the same instruction
1595 // written for a wildcard-aware crawler, and every answer engine on
1596 // the list is one.
1597 if ('disallow' === $field && ('/' === $value || '/*' === $value)) {
1598 foreach ($current as $agent) {
1599 $groups[$agent] = true;
1600 }
1601 }
1602 }
1603
1604 return $groups;
1605 }
1606
1607 /**
1608 * /llms.txt should be published — it is the one file whose entire purpose
1609 * is telling an AI assistant what this site is and what to read.
1610 *
1611 * @since 2.5.0
1612 * @return array
1613 */
1614 public function check_llms_txt(): array {
1615 $label = __('llms.txt is published', 'thinkrank');
1616
1617 if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
1618 return [
1619 'label' => $label,
1620 'status' => self::WARNING,
1621 'message' => __('The llms.txt module is unavailable, so this could not be checked.', 'thinkrank'),
1622 ];
1623 }
1624
1625 $manager = new LLMs_Txt_Manager();
1626
1627 if ($manager->is_published()) {
1628 return [
1629 'label' => $label,
1630 'status' => self::PASSED,
1631 'message' => __('Your llms.txt is published, so AI assistants have a summary of your site and its key pages.', 'thinkrank'),
1632 'value' => home_url('/llms.txt'),
1633 ];
1634 }
1635
1636 return [
1637 'label' => $label,
1638 'status' => self::FAILED,
1639 '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'),
1640 'how_to_fix' => __('Fill in the fields under Essential SEO → Crawling & AI Indexing → LLMs.txt and publish it.', 'thinkrank'),
1641 ];
1642 }
1643
1644 /**
1645 * Answer-shaped schema — FAQ, HowTo or Q&A — is what lets an engine lift a
1646 * question and its answer as a pair instead of guessing at prose.
1647 *
1648 * @since 2.5.0
1649 * @return array
1650 */
1651 public function check_answer_ready_schema(): array {
1652 $label = __('Answer-ready structured data', 'thinkrank');
1653
1654 if ($this->has_answer_schema()) {
1655 return [
1656 'label' => $label,
1657 'status' => self::PASSED,
1658 'message' => __('Your site publishes FAQ, How-To or Q&A structured data, which AI answers can quote question-and-answer pairs from directly.', 'thinkrank'),
1659 ];
1660 }
1661
1662 $how_to_fix = __('Add a ThinkRank FAQ or How-To block, widget or element to your key pages, or enable the FAQPage / HowTo schema types under Essential SEO → Schema.', 'thinkrank');
1663
1664 // Article/WebPage schema still tells an engine what the page is; the
1665 // gap is the answer pairing, not structured data as a whole.
1666 if ($this->schema_is_output()) {
1667 return [
1668 'label' => $label,
1669 'status' => self::WARNING,
1670 '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'),
1671 'how_to_fix' => $how_to_fix,
1672 ];
1673 }
1674
1675 return [
1676 'label' => $label,
1677 'status' => self::FAILED,
1678 'message' => __('Your pages publish no structured data at all, so an AI assistant has to infer what each page is from its prose.', 'thinkrank'),
1679 'how_to_fix' => $how_to_fix,
1680 ];
1681 }
1682
1683 /**
1684 * Whether the site publishes FAQ / HowTo / Q&A structured data.
1685 *
1686 * Three sources, because three things emit it: the Schema Management
1687 * System's enabled types, a per-post-type schema_type in Global SEO, and
1688 * ThinkRank's own FAQ/HowTo blocks, which output their schema from the
1689 * block itself with nothing to configure.
1690 *
1691 * @since 2.5.0
1692 * @return bool
1693 */
1694 private function has_answer_schema(): bool {
1695 $answer_types = ['faqpage', 'faq', 'howto', 'qapage'];
1696
1697 if (class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
1698 $settings = (new Schema_Management_System())->get_settings('site', null);
1699
1700 if (is_array($settings) && !empty($settings['enabled'])) {
1701 $enabled = $settings['enabled_schema_types'] ?? [];
1702 if (is_array($enabled)) {
1703 foreach ($enabled as $type) {
1704 if (in_array(strtolower((string) $type), $answer_types, true)) {
1705 return true;
1706 }
1707 }
1708 }
1709 }
1710 }
1711
1712 $global = get_option('thinkrank_global_seo_settings', []);
1713 if (is_array($global)) {
1714 foreach ($global as $per_type) {
1715 $type = is_array($per_type) ? strtolower((string) ($per_type['schema_type'] ?? '')) : '';
1716 if ('' !== $type && in_array($type, $answer_types, true)) {
1717 return true;
1718 }
1719 }
1720 }
1721
1722 // The blocks carry their own schema, so a single page using one is a
1723 // true positive even with every schema setting untouched.
1724 foreach ($this->get_content_sample() as $row) {
1725 if ($this->has_answer_content((int) ($row['id'] ?? 0), (string) $row['content'])) {
1726 return true;
1727 }
1728 }
1729
1730 return false;
1731 }
1732
1733 /**
1734 * Whether one post carries a ThinkRank FAQ or How-To that emits schema.
1735 *
1736 * Four surfaces, because `Schema_Graph` collects from four: the Gutenberg
1737 * block in `post_content`, the Elementor widget, the Bricks element and the
1738 * Beaver Builder module — the last three living in postmeta. Reading only
1739 * the block would tell a site whose FAQs are built in a page builder that it
1740 * publishes no FAQ schema while the graph is publishing exactly that.
1741 *
1742 * @since 2.5.0
1743 * @param int $post_id Post to inspect; 0 skips the builder surfaces.
1744 * @param string $content Raw post content.
1745 * @return bool
1746 */
1747 private function has_answer_content(int $post_id, string $content): bool {
1748 if (false !== strpos($content, 'wp:thinkrank/faq')
1749 || false !== strpos($content, 'wp:thinkrank/howto')) {
1750 return true;
1751 }
1752
1753 if ($post_id <= 0) {
1754 return false;
1755 }
1756
1757 // Elementor stores its tree as JSON, so the widget name appears verbatim.
1758 $elementor = get_post_meta($post_id, '_elementor_data', true);
1759 if (is_string($elementor)
1760 && (false !== strpos($elementor, '"' . self::ANSWER_FAQ_NAME . '"')
1761 || false !== strpos($elementor, '"' . self::ANSWER_HOWTO_NAME . '"'))) {
1762 return true;
1763 }
1764
1765 // Bricks: the tree it will actually render, resolved the same way
1766 // Schema_Graph resolves it, so templates and components are covered.
1767 if ($this->bricks_has_answer_element($post_id)) {
1768 return true;
1769 }
1770
1771 // Beaver Builder keeps its layout in postmeta as a map of node objects.
1772 $layout = get_post_meta($post_id, '_fl_builder_data', true);
1773 if (is_array($layout)) {
1774 foreach ($layout as $node) {
1775 $settings = is_object($node) ? ($node->settings ?? null) : ($node['settings'] ?? null);
1776 $settings = is_object($settings) ? get_object_vars($settings) : $settings;
1777 $type = is_array($settings) ? (string) ($settings['type'] ?? '') : '';
1778
1779 if (self::ANSWER_FAQ_NAME === $type || self::ANSWER_HOWTO_NAME === $type) {
1780 return true;
1781 }
1782 }
1783 }
1784
1785 return false;
1786 }
1787
1788 /**
1789 * Whether a Bricks-rendered post holds a ThinkRank FAQ or How-To element.
1790 *
1791 * @since 2.5.0
1792 * @param int $post_id Post to inspect.
1793 * @return bool
1794 */
1795 private function bricks_has_answer_element(int $post_id): bool {
1796 if (!class_exists('ThinkRank\\SEO\\Builder_Content')) {
1797 $file = THINKRANK_PLUGIN_DIR . 'includes/seo/class-builder-content.php';
1798 if (!file_exists($file)) {
1799 return false;
1800 }
1801 require_once $file;
1802 }
1803
1804 foreach (Builder_Content::bricks_tree($post_id) as $element) {
1805 $name = is_array($element) ? (string) ($element['name'] ?? '') : '';
1806
1807 if (self::ANSWER_FAQ_NAME === $name || self::ANSWER_HOWTO_NAME === $name) {
1808 return true;
1809 }
1810 }
1811
1812 return false;
1813 }
1814
1815 /**
1816 * Pages should open with a short, self-contained answer an engine can lift.
1817 *
1818 * @since 2.5.0
1819 * @return array
1820 */
1821 public function check_direct_answer(): array {
1822 return $this->coverage_check(
1823 __('Pages open with a direct answer', 'thinkrank'),
1824 function (array $row): bool {
1825 $words = $this->word_count($this->opening_passage($row['content']));
1826
1827 return $words >= self::GEO_ANSWER_MIN_WORDS && $words <= self::GEO_ANSWER_MAX_WORDS;
1828 },
1829 /* translators: 1: matching posts, 2: sampled posts. */
1830 __('%1$d of your %2$d most recent pages open with a concise, quotable answer.', 'thinkrank'),
1831 /* translators: 1: posts without one, 2: sampled posts. */
1832 __('%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'),
1833 __('Open each page with a 40–60 word paragraph that answers its title directly, before any background or introduction.', 'thinkrank'),
1834 __('No published content to check yet.', 'thinkrank')
1835 );
1836 }
1837
1838 /**
1839 * The first block of prose in a post, before any heading.
1840 *
1841 * @since 2.5.0
1842 * @param string $content Raw post content.
1843 * @return string Plain text of the opening passage.
1844 */
1845 private function opening_passage(string $content): string {
1846 // Cut at the first heading: everything above it is the intro, and an
1847 // intro that runs past a heading is not a direct answer either way.
1848 $chunks = preg_split('/<h[1-6][^>]*>/i', $content, 3) ?: [];
1849 $intro = (string) ($chunks[0] ?? $content);
1850
1851 $passage = $this->first_paragraph_text($intro);
1852 if ('' !== $passage) {
1853 return $passage;
1854 }
1855
1856 $text = $this->content_to_text($intro);
1857
1858 // A page that opens with its heading has nothing above it, so the chunk
1859 // read so far is empty and the answer sits directly under that heading.
1860 // Scoring it as a missing opening would fail exactly the pages written
1861 // as "question, then answer" — the shape this check exists to reward.
1862 if ('' === trim($text) && isset($chunks[1])) {
1863 // Drop the heading's own text, which the split left at the head of
1864 // the next chunk, so a long heading cannot pose as the answer.
1865 $after = preg_replace('/^.*?<\/h[1-6]>/is', '', (string) $chunks[1], 1);
1866 $after = null === $after ? (string) $chunks[1] : $after;
1867
1868 $passage = $this->first_paragraph_text($after);
1869 if ('' !== $passage) {
1870 return $passage;
1871 }
1872
1873 return $this->content_to_text($after);
1874 }
1875
1876 return $text;
1877 }
1878
1879 /**
1880 * The first paragraph of a chunk of content with enough words to be a
1881 * passage rather than a caption or a stray line.
1882 *
1883 * Only the first one counts — a three-paragraph intro is exactly the
1884 * preamble the direct-answer check is looking for.
1885 *
1886 * @since 2.5.0
1887 * @param string $chunk Raw content fragment.
1888 * @return string Plain text, or '' when the chunk holds no paragraph.
1889 */
1890 private function first_paragraph_text(string $chunk): string {
1891 $paragraphs = preg_split('/<\/p>|\R{2,}/', $chunk) ?: [];
1892
1893 foreach ($paragraphs as $paragraph) {
1894 $candidate = $this->content_to_text((string) $paragraph);
1895 if ($this->word_count($candidate) >= 5) {
1896 return $candidate;
1897 }
1898 }
1899
1900 return '';
1901 }
1902
1903 /**
1904 * Question-shaped H2/H3 headings map a page onto the questions people
1905 * actually ask an assistant.
1906 *
1907 * @since 2.5.0
1908 * @return array
1909 */
1910 public function check_question_headings(): array {
1911 return $this->coverage_check(
1912 __('Headings phrased as questions', 'thinkrank'),
1913 function (array $row): bool {
1914 return $this->has_question_heading($row['content']);
1915 },
1916 /* translators: 1: matching posts, 2: sampled posts. */
1917 __('%1$d of your %2$d most recent pages use question-style headings.', 'thinkrank'),
1918 /* translators: 1: posts without one, 2: sampled posts. */
1919 __('%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'),
1920 __('Phrase at least one H2 or H3 per page as the question it answers — "How does X work?" rather than "Overview".', 'thinkrank'),
1921 __('No published content to check yet.', 'thinkrank')
1922 );
1923 }
1924
1925 /**
1926 * Whether any H2/H3 in the content reads as a question.
1927 *
1928 * @since 2.5.0
1929 * @param string $content Raw post content.
1930 * @return bool
1931 */
1932 private function has_question_heading(string $content): bool {
1933 if (!preg_match_all('/<h[23][^>]*>(.*?)<\/h[23]>/is', $content, $matches)) {
1934 return false;
1935 }
1936
1937 $starters = ['what', 'why', 'how', 'when', 'where', 'who', 'which', 'can', 'do', 'does', 'is', 'are', 'should', 'will'];
1938
1939 foreach ($matches[1] as $heading) {
1940 $text = $this->content_to_text((string) $heading);
1941
1942 if ('' === $text) {
1943 continue;
1944 }
1945
1946 if ('?' === substr($text, -1)) {
1947 return true;
1948 }
1949
1950 // A question mark is the reliable signal, but plenty of good
1951 // question headings drop it ("How image search works").
1952 $first = strtolower((string) strtok($text, " \t\n"));
1953 if (in_array($first, $starters, true)) {
1954 return true;
1955 }
1956 }
1957
1958 return false;
1959 }
1960
1961 /**
1962 * Lists and tables are the shapes an engine extracts most reliably.
1963 *
1964 * @since 2.5.0
1965 * @return array
1966 */
1967 public function check_structured_content(): array {
1968 return $this->coverage_check(
1969 __('Content uses lists or tables', 'thinkrank'),
1970 static function (array $row): bool {
1971 return (bool) preg_match('/<(ul|ol|table)[\s>]/i', $row['content']);
1972 },
1973 /* translators: 1: matching posts, 2: sampled posts. */
1974 __('%1$d of your %2$d most recent pages present information in lists or tables.', 'thinkrank'),
1975 /* translators: 1: posts without one, 2: sampled posts. */
1976 __('%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'),
1977 __('Break steps, comparisons and specifications out of the paragraphs into list or table blocks.', 'thinkrank'),
1978 __('No published content to check yet.', 'thinkrank')
1979 );
1980 }
1981
1982 /**
1983 * Thin pages are not cited: there is nothing in them worth quoting.
1984 *
1985 * @since 2.5.0
1986 * @return array
1987 */
1988 public function check_content_depth(): array {
1989 return $this->coverage_check(
1990 __('Pages have enough depth to cite', 'thinkrank'),
1991 function (array $row): bool {
1992 return $this->word_count($row['text']) >= self::GEO_DEPTH_MIN_WORDS;
1993 },
1994 /* translators: 1: matching posts, 2: sampled posts. */
1995 __('%1$d of your %2$d most recent pages have enough substance for an assistant to cite.', 'thinkrank'),
1996 /* translators: 1: thin posts, 2: sampled posts. */
1997 __('%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'),
1998 __('Expand thin pages so each one answers its topic completely, or merge them into a page that does.', 'thinkrank'),
1999 __('No published content to check yet.', 'thinkrank')
2000 );
2001 }
2002
2003 /**
2004 * Answer engines strongly prefer recently-revised sources.
2005 *
2006 * @since 2.5.0
2007 * @return array
2008 */
2009 public function check_content_freshness(): array {
2010 $cutoff = time() - self::GEO_FRESHNESS_MAX_AGE;
2011
2012 return $this->coverage_check(
2013 __('Content has been updated recently', 'thinkrank'),
2014 static function (array $row) use ($cutoff): bool {
2015 return $row['modified'] > 0 && $row['modified'] >= $cutoff;
2016 },
2017 /* translators: 1: matching posts, 2: sampled posts. */
2018 __('%1$d of your %2$d most recent pages were revised within the last year.', 'thinkrank'),
2019 /* translators: 1: stale posts, 2: sampled posts. */
2020 __('%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'),
2021 __('Review your most important pages, update what has changed, and save them so their modified date reflects the revision.', 'thinkrank'),
2022 __('No published content to check yet.', 'thinkrank')
2023 );
2024 }
2025 }
2026