PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/seo/class-seo-analyzer-fixer.php +191 -1 2.1.0 → 2.9.0 View file →
@@ -35,8 +35,18 @@
35 35 */
36 36 class SEO_Analyzer_Fixer {
37 37
38 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 + /**
39 49 * The fixable checks, keyed by the analyzer's check id.
40 50 *
41 51 * `warning` is shown in the UI before the user commits when a fix has a
42 52 * consequence worth naming.
@@ -62,8 +72,19 @@
62 72 'image_alt_text' => [
63 73 'label' => __('Fill in missing image alt text', 'thinkrank'),
64 74 'warning' => __('Runs in batches over your media library. With AI alt text enabled this uses your AI provider key.', 'thinkrank'),
65 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 + ],
66 87 ];
67 88
68 89 /**
69 90 * Filter the checks the analyzer can fix automatically.
@@ -108,8 +129,12 @@
108 129 case 'schema':
109 130 return $this->fix_schema();
110 131 case 'image_alt_text':
111 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();
112 137 }
113 138
114 139 /**
115 140 * Let a third party handle a fix it registered.
@@ -217,14 +242,66 @@
217 242 *
218 243 * @return array
219 244 */
220 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.
221 251 $manager = new Image_SEO_Manager();
222 - $result = $manager->bulk_fill_missing_alt(['limit' => 50, 'overwrite' => false]);
223 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 +
224 278 $updated = (int) ($result['updated'] ?? 0);
225 279 $remaining = (int) ($result['remaining'] ?? 0);
280 + $done = !empty($result['done']);
226 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 +
227 304 return [
228 305 'fixed' => true,
229 306 'message' => $remaining > 0
230 307 ? sprintf(
@@ -241,7 +318,120 @@
241 318 'data' => [
242 319 'updated' => $updated,
243 320 'remaining' => $remaining,
244 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' => [],
245 435 ];
246 436 }
247 437 }