PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 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 All 58 releases
thinkrank / includes / seo / class-seo-analyzer-fixer.php

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

486 lines 19.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * One-click fixes for Site SEO Analyzer findings.
4 *
5 * Deliberately conservative. A check is only fixable when the remedy is an
6 * unambiguous SETTINGS change we can make correctly and the user can undo.
7 * Three categories are excluded on principle:
8 *
9 * - **Content.** Site title, tagline and meta descriptions are the user's
10 * words. Writing them for them (even with AI) is authorship, not a fix.
11 * - **Destructive.** Permalink structure is the classic example: switching it
12 * is the textbook SEO recommendation AND it 404s every existing URL. That
13 * needs redirects and a human decision, so it stays advisory.
14 * - **Out of reach.** HTTPS, PHP version, object cache and the wp-config
15 * constants (DISALLOW_FILE_EDIT, WP_DEBUG_DISPLAY) are server or file-system
16 * concerns. Pretending to fix them would be worse than explaining them.
17 *
18 * Everything here is user-initiated: a fix only runs when someone clicks it on
19 * a finding the analyzer already explained.
20 *
21 * @package ThinkRank\SEO
22 * @since 1.28.0
23 */
24
25 declare(strict_types=1);
26
27 namespace ThinkRank\SEO;
28
29 use ThinkRank\Config\Schema_Settings_Config;
30
31 if (!defined('ABSPATH')) {
32 exit;
33 }
34
35 /**
36 * Applies safe, reversible fixes for analyzer checks.
37 */
38 class SEO_Analyzer_Fixer {
39
40 /**
41 * Option holding the alt-text bulk-fill pager position.
42 *
43 * Not autoloaded: it is read only while a fix is running.
44 *
45 * @since 2.2.0
46 * @var string
47 */
48 private const ALT_FIX_OFFSET_OPTION = 'thinkrank_alt_fix_offset';
49
50 /**
51 * The fixable checks, keyed by the analyzer's check id.
52 *
53 * `warning` is shown in the UI before the user commits when a fix has a
54 * consequence worth naming.
55 *
56 * @return array<string, array{label: string, warning: string}>
57 */
58 public static function fixable(): array {
59 $fixable = [
60 'search_visibility' => [
61 'label' => __('Allow search engines to index this site', 'thinkrank'),
62 // Worth stating plainly: this is exactly the switch people use
63 // to keep a staging site out of Google.
64 'warning' => __('This makes your site visible to search engines. Do not apply it on a staging or private site.', 'thinkrank'),
65 ],
66 'xml_sitemap' => [
67 'label' => __('Enable the XML sitemap', 'thinkrank'),
68 'warning' => '',
69 ],
70 'schema' => [
71 'label' => __('Turn on automatic structured data', 'thinkrank'),
72 'warning' => '',
73 ],
74 'image_alt_text' => [
75 'label' => __('Fill in missing image alt text', 'thinkrank'),
76 'warning' => __('Runs in batches over your media library. With AI alt text enabled this uses your AI provider key.', 'thinkrank'),
77 ],
78 'ai_crawler_access' => [
79 'label' => __('Allow AI answer engines to crawl this site', 'thinkrank'),
80 // Only the answer engines are unblocked. Saying so matters:
81 // people block the training crawlers on purpose, and a fix
82 // that quietly reversed that decision would be a betrayal.
83 'warning' => __('Sets the answer-engine crawlers (ChatGPT, Claude, Perplexity, Google-Extended) to Allow. Crawlers that only collect training data keep whatever setting you gave them.', 'thinkrank'),
84 ],
85 'llms_txt' => [
86 'label' => __('Publish llms.txt', 'thinkrank'),
87 'warning' => __('Publishes /llms.txt from your saved LLMs.txt settings. It needs those fields filled in first.', 'thinkrank'),
88 ],
89 ];
90
91 /**
92 * Filter the checks the analyzer can fix automatically.
93 *
94 * Pro/add-ons registering their own checks through
95 * `thinkrank_seo_analyzer_checks` can register their fixes here.
96 *
97 * @since 1.28.0
98 *
99 * @param array $fixable Check id => ['label' => string, 'warning' => string].
100 */
101 return apply_filters('thinkrank_seo_analyzer_fixable', $fixable);
102 }
103
104 /**
105 * Whether a check can be fixed automatically.
106 *
107 * @param string $check_id Analyzer check id.
108 * @return bool
109 */
110 public static function can_fix(string $check_id): bool {
111 return isset(self::fixable()[$check_id]);
112 }
113
114 /**
115 * Apply the fix for a check.
116 *
117 * @param string $check_id Analyzer check id.
118 * @return array{fixed: bool, message: string, data: array} Outcome.
119 * @throws \Exception When the check is unknown or unfixable.
120 */
121 public function fix(string $check_id): array {
122 if (!self::can_fix($check_id)) {
123 throw new \Exception(esc_html__('This issue cannot be fixed automatically.', 'thinkrank'));
124 }
125
126 switch ($check_id) {
127 case 'search_visibility':
128 return $this->fix_search_visibility();
129 case 'xml_sitemap':
130 return $this->fix_sitemap();
131 case 'schema':
132 return $this->fix_schema();
133 case 'image_alt_text':
134 return $this->fix_image_alt_text();
135 case 'ai_crawler_access':
136 return $this->fix_ai_crawler_access();
137 case 'llms_txt':
138 return $this->fix_llms_txt();
139 }
140
141 /**
142 * Let a third party handle a fix it registered.
143 *
144 * @since 1.28.0
145 *
146 * @param array|null $result ['fixed' => bool, 'message' => string, 'data' => array].
147 * @param string $check_id Check id being fixed.
148 */
149 $result = apply_filters('thinkrank_seo_analyzer_apply_fix', null, $check_id);
150
151 if (is_array($result)) {
152 return array_merge(['fixed' => false, 'message' => '', 'data' => []], $result);
153 }
154
155 throw new \Exception(esc_html__('No handler is registered for this fix.', 'thinkrank'));
156 }
157
158 /**
159 * Untick "Discourage search engines from indexing this site".
160 *
161 * @return array
162 */
163 private function fix_search_visibility(): array {
164 update_option('blog_public', 1);
165
166 return [
167 'fixed' => true,
168 'message' => __('Search engines can now index this site.', 'thinkrank'),
169 'data' => [],
170 ];
171 }
172
173 /**
174 * Enable XML sitemap output.
175 *
176 * @return array
177 * @throws \Exception When the sitemap setting cannot be saved.
178 */
179 private function fix_sitemap(): array {
180 // 'site' is the context every real save path uses; 'global' is not a
181 // supported context and Abstract_SEO_Manager rejects it — which this
182 // fixer originally ignored, reporting success while saving nothing.
183 $generator = new Sitemap_Generator();
184 $settings = $generator->get_settings('site');
185 $settings = is_array($settings) ? $settings : [];
186
187 $settings['enabled'] = true;
188
189 if (!$generator->save_settings('site', null, $settings)) {
190 throw new \Exception(esc_html__('The sitemap setting could not be saved. Check the PHP error log for the ThinkRank line naming the cause.', 'thinkrank'));
191 }
192
193 return [
194 'fixed' => true,
195 'message' => __('Your XML sitemap is now enabled.', 'thinkrank'),
196 'data' => [],
197 ];
198 }
199
200 /**
201 * Turn on automatic schema generation.
202 *
203 * @return array
204 * @throws \Exception When the schema module is unavailable or the setting cannot be saved.
205 */
206 private function fix_schema(): array {
207 if (!class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
208 throw new \Exception(esc_html__('The schema module is unavailable.', 'thinkrank'));
209 }
210
211 // 'site' is the storage context; 'schema_management_system' is the
212 // MANAGER type, not a context — passing it made save_settings() reject
213 // the write while this fixer reported success.
214 $schema = new Schema_Management_System();
215 $settings = $schema->get_settings('site');
216 $settings = is_array($settings) ? $settings : [];
217
218 // The audit fails while the master switch is off, and nothing is
219 // emitted then, so "now enabled" was only true once it is on too.
220 $settings['enabled'] = true;
221 $settings['auto_generate_schema'] = true;
222
223 // Seed a site-level selection so enabling the toggle produces output
224 // rather than an empty config. The site context holds the site
225 // entities only. Seeding Article/WebPage made the auto-deploy pass
226 // treat a selection the grid cannot even show as the user's choice,
227 // and retire every deployed Organization, WebSite, LocalBusiness and
228 // Person (#1004).
229 $selected = is_array($settings['enabled_schema_types'] ?? null) ? $settings['enabled_schema_types'] : [];
230 if (empty(Schema_Management_System::filter_types_for_context('site', $selected))) {
231 $settings['enabled_schema_types'] = self::site_schema_types_to_seed(
232 array_keys($schema->get_deployed_schemas('site', null))
233 );
234 }
235
236 if (!$schema->save_settings('site', null, $settings)) {
237 throw new \Exception(esc_html__('The schema setting could not be saved. Check the PHP error log for the ThinkRank line naming the cause.', 'thinkrank'));
238 }
239
240 return [
241 'fixed' => true,
242 'message' => __('Automatic structured data is now enabled.', 'thinkrank'),
243 'data' => [],
244 ];
245 }
246
247 /**
248 * The site Schema Types a schema fix seeds.
249 *
250 * The types already live on the site come first: they are what the page
251 * publishes now, so seeding them turns the setting on without retiring
252 * anything. The site defaults would not do that, since they leave out
253 * WebSite and Person, and auto-deploy retires every deployed type the
254 * selection leaves out. A site with nothing deployed gets the site
255 * defaults from Schema_Settings_Config.
256 *
257 * @since 2.15.0
258 *
259 * @param string[] $deployed_types Schema types deployed in the site context.
260 * @return string[] Site-level schema types to save as the selection.
261 */
262 private static function site_schema_types_to_seed(array $deployed_types): array {
263 $deployed = Schema_Management_System::filter_types_for_context('site', $deployed_types);
264 if (!empty($deployed)) {
265 return $deployed;
266 }
267
268 $defaults = Schema_Settings_Config::get_default_settings('site')['enabled_schema_types'] ?? [];
269
270 return Schema_Management_System::filter_types_for_context('site', is_array($defaults) ? $defaults : []);
271 }
272
273 /**
274 * Fill missing alt text across one batch of the media library.
275 *
276 * Returns progress rather than looping to completion: a large library
277 * would exceed the request timeout, and in AI mode each image is a paid
278 * call. The client re-invokes while `remaining` is above zero, so the user
279 * sees progress and can stop.
280 *
281 * @return array
282 */
283 private function fix_image_alt_text(): array {
284 // bulk_fill_missing_alt() pages by ATTACHMENT offset, so a caller that
285 // always starts at 0 can only ever touch the first batch: once those
286 // images have alt text they are skipped, `remaining` never moves, and
287 // the "run the fix again to continue" message is an instruction that
288 // cannot work. Carry the pager position across clicks.
289 $manager = new Image_SEO_Manager();
290
291 // Nothing missing means nothing to walk. Without this the pager still
292 // marches through the library reporting `remaining` from the total
293 // attachment count, so a fully-covered library kept claiming work was
294 // left and re-armed the offset on every click.
295 $stats = $manager->get_media_alt_stats();
296 if (0 === (int) ($stats['missing'] ?? 0)) {
297 delete_option(self::ALT_FIX_OFFSET_OPTION);
298
299 return [
300 'fixed' => true,
301 'message' => __('Every image in your media library already has alt text.', 'thinkrank'),
302 'data' => [
303 'updated' => 0,
304 'remaining' => 0,
305 ],
306 ];
307 }
308
309 $offset = (int) get_option(self::ALT_FIX_OFFSET_OPTION, 0);
310 $result = $manager->bulk_fill_missing_alt([
311 'offset' => $offset,
312 'limit' => 50,
313 'overwrite' => false,
314 ]);
315
316 $updated = (int) ($result['updated'] ?? 0);
317 $remaining = (int) ($result['remaining'] ?? 0);
318 $done = !empty($result['done']);
319
320 // Reset when the walk finishes so a later run (after new uploads)
321 // starts from the top rather than off the end of the library.
322 if ($done) {
323 delete_option(self::ALT_FIX_OFFSET_OPTION);
324 } else {
325 update_option(self::ALT_FIX_OFFSET_OPTION, (int) ($result['next_offset'] ?? 0), false);
326 }
327
328 // A batch that changed nothing and has nothing left to walk is not a
329 // success. Reporting `fixed => true` here showed a green toast while
330 // the finding below it stayed red.
331 if (0 === $updated && $done) {
332 return [
333 'fixed' => false,
334 'message' => __('No images could be filled automatically. Check your alt text format under Essential SEO → Image SEO, or add alt text manually in the Media Library.', 'thinkrank'),
335 'data' => [
336 'updated' => 0,
337 'remaining' => $remaining,
338 ],
339 ];
340 }
341
342 $message = $remaining > 0
343 ? sprintf(
344 /* translators: 1: images updated in this batch, 2: images still to process. */
345 __('Added alt text to %1$d images. %2$d still to go. Run the fix again to continue.', 'thinkrank'),
346 $updated,
347 $remaining
348 )
349 : sprintf(
350 /* translators: %d: number of images updated. */
351 __('Added alt text to %d images. Your media library is done.', 'thinkrank'),
352 $updated
353 );
354
355 // The write is done, but a cache ThinkRank cannot reach still decides
356 // what the visitor sees. Saying so beats a green toast over a page
357 // that has not changed (#763).
358 $warnings = (array) ($result['cache']['warnings'] ?? []);
359 if ([] !== $warnings) {
360 $message .= ' ' . implode(' ', $warnings);
361 }
362
363 return [
364 'fixed' => true,
365 'message' => $message,
366 'data' => [
367 'updated' => $updated,
368 'remaining' => $remaining,
369 ],
370 ];
371 }
372
373 /**
374 * Set every AI answer-engine crawler to `allow`.
375 *
376 * Deliberately narrow: only the crawlers that decide whether the site can
377 * be CITED are touched. GPTBot, ClaudeBot, CCBot and the other
378 * training-only agents keep whatever the user chose for them — blocking
379 * those is an editorial position, not a misconfiguration.
380 *
381 * @since 2.5.0
382 * @return array
383 * @throws \Exception When the rules cannot be saved, or when something
384 * other than the rule map is still blocking them.
385 */
386 private function fix_ai_crawler_access(): array {
387 $manager = new Site_Identity_Manager();
388 $settings = $manager->get_settings('site');
389 $settings = is_array($settings) ? $settings : [];
390
391 $rules = isset($settings['ai_crawler_rules']) && is_array($settings['ai_crawler_rules'])
392 ? $settings['ai_crawler_rules']
393 : [];
394
395 foreach (SEO_Analyzer::GEO_ANSWER_AGENTS as $slug) {
396 if (AI_Crawlers::exists($slug)) {
397 $rules[$slug] = 'allow';
398 }
399 }
400
401 $settings['ai_crawler_rules'] = $rules;
402
403 if (!$manager->save_settings('site', null, $settings)) {
404 throw new \Exception(esc_html__('The AI crawler rules could not be saved. Check the PHP error log for the ThinkRank line naming the cause.', 'thinkrank'));
405 }
406
407 // The directives are composed into the served body at render time, but
408 // a physical robots.txt in the web root is a copy the web server hands
409 // out directly — saving the rules does not touch it. Re-sync from a
410 // fresh manager (the one above holds the pre-save settings) or the fix
411 // changes only the file nobody is reading.
412 (new Site_Identity_Manager())->sync_robots_txt_file();
413
414 // The rules are only one of the things that can disallow a crawler: a
415 // hand-written robots.txt body, a physical robots.txt in the web root,
416 // and the site-wide search block all outrank them. Ask the served file
417 // again rather than reporting a success it contradicts.
418 $still_blocked = (new SEO_Analyzer())->blocked_answer_agents();
419
420 if (!empty($still_blocked)) {
421 throw new \Exception(
422 sprintf(
423 /* translators: %s: comma-separated crawler names. */
424 esc_html__('The crawler rules were saved, but your robots.txt still blocks %s. That block comes from your own robots.txt content, a robots.txt file in your site root, or the site-wide search-engine setting. Edit it under Essential SEO → Crawling and AI Indexing.', 'thinkrank'),
425 esc_html(implode(', ', $still_blocked))
426 )
427 );
428 }
429
430 return [
431 'fixed' => true,
432 'message' => __('AI answer engines can now crawl and cite your site.', 'thinkrank'),
433 'data' => [],
434 ];
435 }
436
437 /**
438 * Generate and publish /llms.txt from the saved LLMs.txt settings.
439 *
440 * The document is built from settings the user already wrote, so this is a
441 * publish, not authorship. Incomplete settings produce an invalid document
442 * and are refused rather than written — a half-empty llms.txt is worse
443 * than none, because an assistant will read it and believe it.
444 *
445 * @since 2.5.0
446 * @return array
447 * @throws \Exception When the module is unavailable, the settings are
448 * incomplete, or the write fails.
449 */
450 private function fix_llms_txt(): array {
451 if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
452 throw new \Exception(esc_html__('The llms.txt module is unavailable.', 'thinkrank'));
453 }
454
455 $manager = new LLMs_Txt_Manager();
456 $generated = $manager->generate_llms_txt([], []);
457
458 $validation = isset($generated['validation']) && is_array($generated['validation'])
459 ? $generated['validation']
460 : [];
461
462 if (empty($validation['valid'])) {
463 throw new \Exception(esc_html__('llms.txt could not be published because its settings are incomplete. Fill in Website Description, Key Features and Target Audience under Essential SEO → Crawling and AI Indexing → LLMs.txt, then try again.', 'thinkrank'));
464 }
465
466 $content = (string) ($generated['content'] ?? '');
467 $write = $manager->write_llms_txt_to_file($content);
468
469 if (empty($write['success'])) {
470 $reason = (string) ($write['message'] ?? '');
471
472 throw new \Exception(
473 '' !== $reason
474 ? esc_html($reason)
475 : esc_html__('llms.txt could not be written. Check that your site root is writable.', 'thinkrank')
476 );
477 }
478
479 return [
480 'fixed' => true,
481 'message' => __('Your llms.txt is now published at /llms.txt.', 'thinkrank'),
482 'data' => [],
483 ];
484 }
485 }
486