PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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-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.7.0, at includes/seo/class-seo-analyzer-fixer.php

438 lines 17.5 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 if (!defined('ABSPATH')) {
30 exit;
31 }
32
33 /**
34 * Applies safe, reversible fixes for analyzer checks.
35 */
36 class SEO_Analyzer_Fixer {
37
38 /**
39 * Option holding the alt-text bulk-fill pager position.
40 *
41 * Not autoloaded: it is read only while a fix is running.
42 *
43 * @since 2.2.0
44 * @var string
45 */
46 private const ALT_FIX_OFFSET_OPTION = 'thinkrank_alt_fix_offset';
47
48 /**
49 * The fixable checks, keyed by the analyzer's check id.
50 *
51 * `warning` is shown in the UI before the user commits when a fix has a
52 * consequence worth naming.
53 *
54 * @return array<string, array{label: string, warning: string}>
55 */
56 public static function fixable(): array {
57 $fixable = [
58 'search_visibility' => [
59 'label' => __('Allow search engines to index this site', 'thinkrank'),
60 // Worth stating plainly: this is exactly the switch people use
61 // to keep a staging site out of Google.
62 'warning' => __('This makes your site visible to search engines. Do not apply it on a staging or private site.', 'thinkrank'),
63 ],
64 'xml_sitemap' => [
65 'label' => __('Enable the XML sitemap', 'thinkrank'),
66 'warning' => '',
67 ],
68 'schema' => [
69 'label' => __('Turn on automatic structured data', 'thinkrank'),
70 'warning' => '',
71 ],
72 'image_alt_text' => [
73 'label' => __('Fill in missing image alt text', 'thinkrank'),
74 'warning' => __('Runs in batches over your media library. With AI alt text enabled this uses your AI provider key.', 'thinkrank'),
75 ],
76 'ai_crawler_access' => [
77 'label' => __('Allow AI answer engines to crawl this site', 'thinkrank'),
78 // Only the answer engines are unblocked. Saying so matters:
79 // people block the training crawlers on purpose, and a fix
80 // that quietly reversed that decision would be a betrayal.
81 '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'),
82 ],
83 'llms_txt' => [
84 'label' => __('Publish llms.txt', 'thinkrank'),
85 'warning' => __('Publishes /llms.txt from your saved LLMs.txt settings. It needs those fields filled in first.', 'thinkrank'),
86 ],
87 ];
88
89 /**
90 * Filter the checks the analyzer can fix automatically.
91 *
92 * Pro/add-ons registering their own checks through
93 * `thinkrank_seo_analyzer_checks` can register their fixes here.
94 *
95 * @since 1.28.0
96 *
97 * @param array $fixable Check id => ['label' => string, 'warning' => string].
98 */
99 return apply_filters('thinkrank_seo_analyzer_fixable', $fixable);
100 }
101
102 /**
103 * Whether a check can be fixed automatically.
104 *
105 * @param string $check_id Analyzer check id.
106 * @return bool
107 */
108 public static function can_fix(string $check_id): bool {
109 return isset(self::fixable()[$check_id]);
110 }
111
112 /**
113 * Apply the fix for a check.
114 *
115 * @param string $check_id Analyzer check id.
116 * @return array{fixed: bool, message: string, data: array} Outcome.
117 * @throws \Exception When the check is unknown or unfixable.
118 */
119 public function fix(string $check_id): array {
120 if (!self::can_fix($check_id)) {
121 throw new \Exception(esc_html__('This issue cannot be fixed automatically.', 'thinkrank'));
122 }
123
124 switch ($check_id) {
125 case 'search_visibility':
126 return $this->fix_search_visibility();
127 case 'xml_sitemap':
128 return $this->fix_sitemap();
129 case 'schema':
130 return $this->fix_schema();
131 case 'image_alt_text':
132 return $this->fix_image_alt_text();
133 case 'ai_crawler_access':
134 return $this->fix_ai_crawler_access();
135 case 'llms_txt':
136 return $this->fix_llms_txt();
137 }
138
139 /**
140 * Let a third party handle a fix it registered.
141 *
142 * @since 1.28.0
143 *
144 * @param array|null $result ['fixed' => bool, 'message' => string, 'data' => array].
145 * @param string $check_id Check id being fixed.
146 */
147 $result = apply_filters('thinkrank_seo_analyzer_apply_fix', null, $check_id);
148
149 if (is_array($result)) {
150 return array_merge(['fixed' => false, 'message' => '', 'data' => []], $result);
151 }
152
153 throw new \Exception(esc_html__('No handler is registered for this fix.', 'thinkrank'));
154 }
155
156 /**
157 * Untick "Discourage search engines from indexing this site".
158 *
159 * @return array
160 */
161 private function fix_search_visibility(): array {
162 update_option('blog_public', 1);
163
164 return [
165 'fixed' => true,
166 'message' => __('Search engines can now index this site.', 'thinkrank'),
167 'data' => [],
168 ];
169 }
170
171 /**
172 * Enable XML sitemap output.
173 *
174 * @return array
175 * @throws \Exception When the sitemap setting cannot be saved.
176 */
177 private function fix_sitemap(): array {
178 // 'site' is the context every real save path uses; 'global' is not a
179 // supported context and Abstract_SEO_Manager rejects it — which this
180 // fixer originally ignored, reporting success while saving nothing.
181 $generator = new Sitemap_Generator();
182 $settings = $generator->get_settings('site');
183 $settings = is_array($settings) ? $settings : [];
184
185 $settings['enabled'] = true;
186
187 if (!$generator->save_settings('site', null, $settings)) {
188 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'));
189 }
190
191 return [
192 'fixed' => true,
193 'message' => __('Your XML sitemap is now enabled.', 'thinkrank'),
194 'data' => [],
195 ];
196 }
197
198 /**
199 * Turn on automatic schema generation.
200 *
201 * @return array
202 * @throws \Exception When the schema module is unavailable or the setting cannot be saved.
203 */
204 private function fix_schema(): array {
205 if (!class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
206 throw new \Exception(esc_html__('The schema module is unavailable.', 'thinkrank'));
207 }
208
209 // 'site' is the storage context; 'schema_management_system' is the
210 // MANAGER type, not a context — passing it made save_settings() reject
211 // the write while this fixer reported success.
212 $schema = new Schema_Management_System();
213 $settings = $schema->get_settings('site');
214 $settings = is_array($settings) ? $settings : [];
215
216 $settings['auto_generate_schema'] = true;
217
218 // Seed the two types that apply to virtually every site, so enabling
219 // the toggle actually produces output rather than an empty config.
220 if (empty($settings['enabled_schema_types']) || !is_array($settings['enabled_schema_types'])) {
221 $settings['enabled_schema_types'] = ['Article', 'WebPage'];
222 }
223
224 if (!$schema->save_settings('site', null, $settings)) {
225 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'));
226 }
227
228 return [
229 'fixed' => true,
230 'message' => __('Automatic structured data is now enabled.', 'thinkrank'),
231 'data' => [],
232 ];
233 }
234
235 /**
236 * Fill missing alt text across one batch of the media library.
237 *
238 * Returns progress rather than looping to completion: a large library
239 * would exceed the request timeout, and in AI mode each image is a paid
240 * call. The client re-invokes while `remaining` is above zero, so the user
241 * sees progress and can stop.
242 *
243 * @return array
244 */
245 private function fix_image_alt_text(): array {
246 // bulk_fill_missing_alt() pages by ATTACHMENT offset, so a caller that
247 // always starts at 0 can only ever touch the first batch: once those
248 // images have alt text they are skipped, `remaining` never moves, and
249 // the "run the fix again to continue" message is an instruction that
250 // cannot work. Carry the pager position across clicks.
251 $manager = new Image_SEO_Manager();
252
253 // Nothing missing means nothing to walk. Without this the pager still
254 // marches through the library reporting `remaining` from the total
255 // attachment count, so a fully-covered library kept claiming work was
256 // left and re-armed the offset on every click.
257 $stats = $manager->get_media_alt_stats();
258 if (0 === (int) ($stats['missing'] ?? 0)) {
259 delete_option(self::ALT_FIX_OFFSET_OPTION);
260
261 return [
262 'fixed' => true,
263 'message' => __('Every image in your media library already has alt text.', 'thinkrank'),
264 'data' => [
265 'updated' => 0,
266 'remaining' => 0,
267 ],
268 ];
269 }
270
271 $offset = (int) get_option(self::ALT_FIX_OFFSET_OPTION, 0);
272 $result = $manager->bulk_fill_missing_alt([
273 'offset' => $offset,
274 'limit' => 50,
275 'overwrite' => false,
276 ]);
277
278 $updated = (int) ($result['updated'] ?? 0);
279 $remaining = (int) ($result['remaining'] ?? 0);
280 $done = !empty($result['done']);
281
282 // Reset when the walk finishes so a later run (after new uploads)
283 // starts from the top rather than off the end of the library.
284 if ($done) {
285 delete_option(self::ALT_FIX_OFFSET_OPTION);
286 } else {
287 update_option(self::ALT_FIX_OFFSET_OPTION, (int) ($result['next_offset'] ?? 0), false);
288 }
289
290 // A batch that changed nothing and has nothing left to walk is not a
291 // success. Reporting `fixed => true` here showed a green toast while
292 // the finding below it stayed red.
293 if (0 === $updated && $done) {
294 return [
295 'fixed' => false,
296 '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'),
297 'data' => [
298 'updated' => 0,
299 'remaining' => $remaining,
300 ],
301 ];
302 }
303
304 return [
305 'fixed' => true,
306 'message' => $remaining > 0
307 ? sprintf(
308 /* translators: 1: images updated in this batch, 2: images still to process. */
309 __('Added alt text to %1$d images. %2$d still to go — run the fix again to continue.', 'thinkrank'),
310 $updated,
311 $remaining
312 )
313 : sprintf(
314 /* translators: %d: number of images updated. */
315 __('Added alt text to %d images. Your media library is done.', 'thinkrank'),
316 $updated
317 ),
318 'data' => [
319 'updated' => $updated,
320 'remaining' => $remaining,
321 ],
322 ];
323 }
324
325 /**
326 * Set every AI answer-engine crawler to `allow`.
327 *
328 * Deliberately narrow: only the crawlers that decide whether the site can
329 * be CITED are touched. GPTBot, ClaudeBot, CCBot and the other
330 * training-only agents keep whatever the user chose for them — blocking
331 * those is an editorial position, not a misconfiguration.
332 *
333 * @since 2.5.0
334 * @return array
335 * @throws \Exception When the rules cannot be saved, or when something
336 * other than the rule map is still blocking them.
337 */
338 private function fix_ai_crawler_access(): array {
339 $manager = new Site_Identity_Manager();
340 $settings = $manager->get_settings('site');
341 $settings = is_array($settings) ? $settings : [];
342
343 $rules = isset($settings['ai_crawler_rules']) && is_array($settings['ai_crawler_rules'])
344 ? $settings['ai_crawler_rules']
345 : [];
346
347 foreach (SEO_Analyzer::GEO_ANSWER_AGENTS as $slug) {
348 if (AI_Crawlers::exists($slug)) {
349 $rules[$slug] = 'allow';
350 }
351 }
352
353 $settings['ai_crawler_rules'] = $rules;
354
355 if (!$manager->save_settings('site', null, $settings)) {
356 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'));
357 }
358
359 // The directives are composed into the served body at render time, but
360 // a physical robots.txt in the web root is a copy the web server hands
361 // out directly — saving the rules does not touch it. Re-sync from a
362 // fresh manager (the one above holds the pre-save settings) or the fix
363 // changes only the file nobody is reading.
364 (new Site_Identity_Manager())->sync_robots_txt_file();
365
366 // The rules are only one of the things that can disallow a crawler: a
367 // hand-written robots.txt body, a physical robots.txt in the web root,
368 // and the site-wide search block all outrank them. Ask the served file
369 // again rather than reporting a success it contradicts.
370 $still_blocked = (new SEO_Analyzer())->blocked_answer_agents();
371
372 if (!empty($still_blocked)) {
373 throw new \Exception(
374 sprintf(
375 /* translators: %s: comma-separated crawler names. */
376 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'),
377 esc_html(implode(', ', $still_blocked))
378 )
379 );
380 }
381
382 return [
383 'fixed' => true,
384 'message' => __('AI answer engines can now crawl and cite your site.', 'thinkrank'),
385 'data' => [],
386 ];
387 }
388
389 /**
390 * Generate and publish /llms.txt from the saved LLMs.txt settings.
391 *
392 * The document is built from settings the user already wrote, so this is a
393 * publish, not authorship. Incomplete settings produce an invalid document
394 * and are refused rather than written — a half-empty llms.txt is worse
395 * than none, because an assistant will read it and believe it.
396 *
397 * @since 2.5.0
398 * @return array
399 * @throws \Exception When the module is unavailable, the settings are
400 * incomplete, or the write fails.
401 */
402 private function fix_llms_txt(): array {
403 if (!class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
404 throw new \Exception(esc_html__('The llms.txt module is unavailable.', 'thinkrank'));
405 }
406
407 $manager = new LLMs_Txt_Manager();
408 $generated = $manager->generate_llms_txt([], []);
409
410 $validation = isset($generated['validation']) && is_array($generated['validation'])
411 ? $generated['validation']
412 : [];
413
414 if (empty($validation['valid'])) {
415 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'));
416 }
417
418 $content = (string) ($generated['content'] ?? '');
419 $write = $manager->write_llms_txt_to_file($content);
420
421 if (empty($write['success'])) {
422 $reason = (string) ($write['message'] ?? '');
423
424 throw new \Exception(
425 '' !== $reason
426 ? esc_html($reason)
427 : esc_html__('llms.txt could not be written. Check that your site root is writable.', 'thinkrank')
428 );
429 }
430
431 return [
432 'fixed' => true,
433 'message' => __('Your llms.txt is now published at /llms.txt.', 'thinkrank'),
434 'data' => [],
435 ];
436 }
437 }
438