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

310 lines 11.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 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 ];
77
78 /**
79 * Filter the checks the analyzer can fix automatically.
80 *
81 * Pro/add-ons registering their own checks through
82 * `thinkrank_seo_analyzer_checks` can register their fixes here.
83 *
84 * @since 1.28.0
85 *
86 * @param array $fixable Check id => ['label' => string, 'warning' => string].
87 */
88 return apply_filters('thinkrank_seo_analyzer_fixable', $fixable);
89 }
90
91 /**
92 * Whether a check can be fixed automatically.
93 *
94 * @param string $check_id Analyzer check id.
95 * @return bool
96 */
97 public static function can_fix(string $check_id): bool {
98 return isset(self::fixable()[$check_id]);
99 }
100
101 /**
102 * Apply the fix for a check.
103 *
104 * @param string $check_id Analyzer check id.
105 * @return array{fixed: bool, message: string, data: array} Outcome.
106 * @throws \Exception When the check is unknown or unfixable.
107 */
108 public function fix(string $check_id): array {
109 if (!self::can_fix($check_id)) {
110 throw new \Exception(esc_html__('This issue cannot be fixed automatically.', 'thinkrank'));
111 }
112
113 switch ($check_id) {
114 case 'search_visibility':
115 return $this->fix_search_visibility();
116 case 'xml_sitemap':
117 return $this->fix_sitemap();
118 case 'schema':
119 return $this->fix_schema();
120 case 'image_alt_text':
121 return $this->fix_image_alt_text();
122 }
123
124 /**
125 * Let a third party handle a fix it registered.
126 *
127 * @since 1.28.0
128 *
129 * @param array|null $result ['fixed' => bool, 'message' => string, 'data' => array].
130 * @param string $check_id Check id being fixed.
131 */
132 $result = apply_filters('thinkrank_seo_analyzer_apply_fix', null, $check_id);
133
134 if (is_array($result)) {
135 return array_merge(['fixed' => false, 'message' => '', 'data' => []], $result);
136 }
137
138 throw new \Exception(esc_html__('No handler is registered for this fix.', 'thinkrank'));
139 }
140
141 /**
142 * Untick "Discourage search engines from indexing this site".
143 *
144 * @return array
145 */
146 private function fix_search_visibility(): array {
147 update_option('blog_public', 1);
148
149 return [
150 'fixed' => true,
151 'message' => __('Search engines can now index this site.', 'thinkrank'),
152 'data' => [],
153 ];
154 }
155
156 /**
157 * Enable XML sitemap output.
158 *
159 * @return array
160 * @throws \Exception When the sitemap setting cannot be saved.
161 */
162 private function fix_sitemap(): array {
163 // 'site' is the context every real save path uses; 'global' is not a
164 // supported context and Abstract_SEO_Manager rejects it — which this
165 // fixer originally ignored, reporting success while saving nothing.
166 $generator = new Sitemap_Generator();
167 $settings = $generator->get_settings('site');
168 $settings = is_array($settings) ? $settings : [];
169
170 $settings['enabled'] = true;
171
172 if (!$generator->save_settings('site', null, $settings)) {
173 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'));
174 }
175
176 return [
177 'fixed' => true,
178 'message' => __('Your XML sitemap is now enabled.', 'thinkrank'),
179 'data' => [],
180 ];
181 }
182
183 /**
184 * Turn on automatic schema generation.
185 *
186 * @return array
187 * @throws \Exception When the schema module is unavailable or the setting cannot be saved.
188 */
189 private function fix_schema(): array {
190 if (!class_exists('ThinkRank\\SEO\\Schema_Management_System')) {
191 throw new \Exception(esc_html__('The schema module is unavailable.', 'thinkrank'));
192 }
193
194 // 'site' is the storage context; 'schema_management_system' is the
195 // MANAGER type, not a context — passing it made save_settings() reject
196 // the write while this fixer reported success.
197 $schema = new Schema_Management_System();
198 $settings = $schema->get_settings('site');
199 $settings = is_array($settings) ? $settings : [];
200
201 $settings['auto_generate_schema'] = true;
202
203 // Seed the two types that apply to virtually every site, so enabling
204 // the toggle actually produces output rather than an empty config.
205 if (empty($settings['enabled_schema_types']) || !is_array($settings['enabled_schema_types'])) {
206 $settings['enabled_schema_types'] = ['Article', 'WebPage'];
207 }
208
209 if (!$schema->save_settings('site', null, $settings)) {
210 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'));
211 }
212
213 return [
214 'fixed' => true,
215 'message' => __('Automatic structured data is now enabled.', 'thinkrank'),
216 'data' => [],
217 ];
218 }
219
220 /**
221 * Fill missing alt text across one batch of the media library.
222 *
223 * Returns progress rather than looping to completion: a large library
224 * would exceed the request timeout, and in AI mode each image is a paid
225 * call. The client re-invokes while `remaining` is above zero, so the user
226 * sees progress and can stop.
227 *
228 * @return array
229 */
230 private function fix_image_alt_text(): array {
231 // bulk_fill_missing_alt() pages by ATTACHMENT offset, so a caller that
232 // always starts at 0 can only ever touch the first batch: once those
233 // images have alt text they are skipped, `remaining` never moves, and
234 // the "run the fix again to continue" message is an instruction that
235 // cannot work. Carry the pager position across clicks.
236 $manager = new Image_SEO_Manager();
237
238 // Nothing missing means nothing to walk. Without this the pager still
239 // marches through the library reporting `remaining` from the total
240 // attachment count, so a fully-covered library kept claiming work was
241 // left and re-armed the offset on every click.
242 $stats = $manager->get_media_alt_stats();
243 if (0 === (int) ($stats['missing'] ?? 0)) {
244 delete_option(self::ALT_FIX_OFFSET_OPTION);
245
246 return [
247 'fixed' => true,
248 'message' => __('Every image in your media library already has alt text.', 'thinkrank'),
249 'data' => [
250 'updated' => 0,
251 'remaining' => 0,
252 ],
253 ];
254 }
255
256 $offset = (int) get_option(self::ALT_FIX_OFFSET_OPTION, 0);
257 $result = $manager->bulk_fill_missing_alt([
258 'offset' => $offset,
259 'limit' => 50,
260 'overwrite' => false,
261 ]);
262
263 $updated = (int) ($result['updated'] ?? 0);
264 $remaining = (int) ($result['remaining'] ?? 0);
265 $done = !empty($result['done']);
266
267 // Reset when the walk finishes so a later run (after new uploads)
268 // starts from the top rather than off the end of the library.
269 if ($done) {
270 delete_option(self::ALT_FIX_OFFSET_OPTION);
271 } else {
272 update_option(self::ALT_FIX_OFFSET_OPTION, (int) ($result['next_offset'] ?? 0), false);
273 }
274
275 // A batch that changed nothing and has nothing left to walk is not a
276 // success. Reporting `fixed => true` here showed a green toast while
277 // the finding below it stayed red.
278 if (0 === $updated && $done) {
279 return [
280 'fixed' => false,
281 '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'),
282 'data' => [
283 'updated' => 0,
284 'remaining' => $remaining,
285 ],
286 ];
287 }
288
289 return [
290 'fixed' => true,
291 'message' => $remaining > 0
292 ? sprintf(
293 /* translators: 1: images updated in this batch, 2: images still to process. */
294 __('Added alt text to %1$d images. %2$d still to go — run the fix again to continue.', 'thinkrank'),
295 $updated,
296 $remaining
297 )
298 : sprintf(
299 /* translators: %d: number of images updated. */
300 __('Added alt text to %d images. Your media library is done.', 'thinkrank'),
301 $updated
302 ),
303 'data' => [
304 'updated' => $updated,
305 'remaining' => $remaining,
306 ],
307 ];
308 }
309 }
310