PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.14
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.14
2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 All 139 releases
metasync / wp-mcp-server / tools / class-mcp-tool-bulk-alt-text.php

class-mcp-tool-bulk-alt-text.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.14, at wp-mcp-server/tools/class-mcp-tool-bulk-alt-text.php

664 lines 22.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP Tools for Bulk Alt Text Operations
4 *
5 * Provides MCP tools for auditing and bulk editing image alt text.
6 * Addresses accessibility and image SEO at scale.
7 *
8 * @package MetaSync
9 * @subpackage MCP_Server/Tools
10 * @since 2.8.0
11 */
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 require_once plugin_dir_path(dirname(__FILE__)) . 'class-mcp-tool-base.php';
18
19 /**
20 * Audit Alt Text Tool
21 *
22 * Finds images without alt text or with poor quality alt text
23 */
24 class MCP_Tool_Audit_Alt_Text extends MCP_Tool_Base {
25
26 public function get_name() {
27 return 'wordpress_audit_alt_text';
28 }
29
30 public function get_description() {
31 return 'Audit images for missing or poor quality alt text. Returns images that need attention for accessibility and SEO.';
32 }
33
34 public function get_input_schema() {
35 return [
36 'type' => 'object',
37 'properties' => [
38 'status' => [
39 'type' => 'string',
40 'enum' => ['missing', 'short', 'long', 'all'],
41 'description' => 'Filter by alt text status: missing (no alt text), short (<10 chars), long (>125 chars), all (return all images)',
42 ],
43 'limit' => [
44 'type' => 'integer',
45 'description' => 'Maximum number of images to return (default: 100, max: 500)',
46 'minimum' => 1,
47 'maximum' => 500,
48 ],
49 'offset' => [
50 'type' => 'integer',
51 'description' => 'Number of images to skip (for pagination)',
52 'minimum' => 0,
53 ],
54 'mime_type' => [
55 'type' => 'string',
56 'description' => 'Filter by MIME type (e.g., "image/jpeg", "image/png")',
57 ],
58 ],
59 ];
60 }
61
62 public function execute($params) {
63 $this->validate_params($params);
64 $this->require_capability('upload_files');
65
66 $status = isset($params['status']) ? sanitize_text_field($params['status']) : 'missing';
67 $limit = isset($params['limit']) ? min(intval($params['limit']), 500) : 100;
68 $offset = isset($params['offset']) ? intval($params['offset']) : 0;
69 $mime_type = isset($params['mime_type']) ? sanitize_text_field($params['mime_type']) : '';
70
71 // Query arguments
72 $query_args = [
73 'post_type' => 'attachment',
74 'post_status' => 'inherit',
75 'posts_per_page' => $limit,
76 'offset' => $offset,
77 'post_mime_type' => $mime_type ?: 'image',
78 ];
79
80 $query = new WP_Query($query_args);
81 $images = [];
82 $counts = [
83 'missing' => 0,
84 'short' => 0,
85 'long' => 0,
86 'good' => 0,
87 ];
88
89 foreach ($query->posts as $attachment) {
90 $alt_text = get_post_meta($attachment->ID, '_wp_attachment_image_alt', true);
91 $alt_length = mb_strlen($alt_text);
92
93 // Determine status
94 $image_status = 'good';
95 if (empty($alt_text)) {
96 $image_status = 'missing';
97 $counts['missing']++;
98 } elseif ($alt_length < 10) {
99 $image_status = 'short';
100 $counts['short']++;
101 } elseif ($alt_length > 125) {
102 $image_status = 'long';
103 $counts['long']++;
104 } else {
105 $counts['good']++;
106 }
107
108 // Filter by requested status
109 if ($status !== 'all' && $image_status !== $status) {
110 continue;
111 }
112
113 $metadata = wp_get_attachment_metadata($attachment->ID);
114 $file_size = filesize(get_attached_file($attachment->ID));
115
116 $images[] = [
117 'attachment_id' => $attachment->ID,
118 'url' => wp_get_attachment_url($attachment->ID),
119 'filename' => basename($attachment->guid),
120 'title' => $attachment->post_title,
121 'alt_text' => $alt_text,
122 'alt_length' => $alt_length,
123 'status' => $image_status,
124 'mime_type' => $attachment->post_mime_type,
125 'width' => isset($metadata['width']) ? $metadata['width'] : null,
126 'height' => isset($metadata['height']) ? $metadata['height'] : null,
127 'file_size' => $file_size,
128 'uploaded' => $attachment->post_date,
129 ];
130 }
131
132 // Get total counts
133 $total_query = new WP_Query([
134 'post_type' => 'attachment',
135 'post_status' => 'inherit',
136 'posts_per_page' => -1,
137 'post_mime_type' => $mime_type ?: 'image',
138 'fields' => 'ids',
139 ]);
140 $total_images = $total_query->found_posts;
141
142 return $this->success([
143 'total_images' => $total_images,
144 'images_analyzed' => count($query->posts),
145 'images_returned' => count($images),
146 'counts' => $counts,
147 'filter' => $status,
148 'images' => $images,
149 'recommendations' => $this->get_recommendations($counts, $total_images),
150 ]);
151 }
152
153 /**
154 * Generate recommendations based on audit results
155 */
156 private function get_recommendations($counts, $total) {
157 $recommendations = [];
158
159 $missing_pct = ($counts['missing'] / max($total, 1)) * 100;
160 $short_pct = ($counts['short'] / max($total, 1)) * 100;
161 $long_pct = ($counts['long'] / max($total, 1)) * 100;
162
163 if ($missing_pct > 10) {
164 $recommendations[] = [
165 'severity' => 'high',
166 'issue' => "{$counts['missing']} images ({$missing_pct}%) have no alt text",
167 'action' => 'Add descriptive alt text to improve accessibility and SEO',
168 ];
169 }
170
171 if ($short_pct > 5) {
172 $recommendations[] = [
173 'severity' => 'medium',
174 'issue' => "{$counts['short']} images ({$short_pct}%) have very short alt text (<10 chars)",
175 'action' => 'Expand alt text to be more descriptive (10-125 characters recommended)',
176 ];
177 }
178
179 if ($long_pct > 5) {
180 $recommendations[] = [
181 'severity' => 'low',
182 'issue' => "{$counts['long']} images ({$long_pct}%) have very long alt text (>125 chars)",
183 'action' => 'Shorten alt text to improve readability (125 characters max recommended)',
184 ];
185 }
186
187 if (empty($recommendations)) {
188 $recommendations[] = [
189 'severity' => 'info',
190 'issue' => 'Alt text quality is good',
191 'action' => 'Continue maintaining quality alt text for all images',
192 ];
193 }
194
195 return $recommendations;
196 }
197 }
198
199 /**
200 * Bulk Update Alt Text Tool
201 *
202 * Updates alt text for multiple images at once
203 */
204 class MCP_Tool_Bulk_Update_Alt_Text extends MCP_Tool_Base {
205
206 public function get_name() {
207 return 'wordpress_bulk_update_alt_text';
208 }
209
210 public function get_description() {
211 return 'Update alt text for multiple images at once (max 100 images per request)';
212 }
213
214 public function get_input_schema() {
215 return [
216 'type' => 'object',
217 'properties' => [
218 'updates' => [
219 'type' => 'array',
220 'description' => 'Array of alt text updates',
221 'items' => [
222 'type' => 'object',
223 'properties' => [
224 'attachment_id' => [
225 'type' => 'integer',
226 'description' => 'Attachment ID',
227 ],
228 'alt_text' => [
229 'type' => 'string',
230 'description' => 'New alt text',
231 ],
232 ],
233 'required' => ['attachment_id', 'alt_text'],
234 ],
235 'maxItems' => 100,
236 ],
237 ],
238 'required' => ['updates'],
239 ];
240 }
241
242 public function execute($params) {
243 $this->validate_params($params);
244 $this->require_capability('upload_files');
245
246 if (!is_array($params['updates'])) {
247 throw new Exception('updates must be an array');
248 }
249
250 $updates = $params['updates'];
251
252 if (count($updates) > 100) {
253 throw new Exception('Maximum 100 images can be updated at once');
254 }
255
256 $results = [
257 'success' => [],
258 'failed' => [],
259 ];
260
261 foreach ($updates as $update) {
262 try {
263 if (!isset($update['attachment_id']) || !isset($update['alt_text'])) {
264 throw new Exception('Missing required fields: attachment_id, alt_text');
265 }
266
267 $attachment_id = intval($update['attachment_id']);
268 $alt_text = sanitize_text_field($update['alt_text']);
269
270 // Verify attachment exists
271 $attachment = get_post($attachment_id);
272 if (!$attachment || $attachment->post_type !== 'attachment') {
273 $results['failed'][] = [
274 'attachment_id' => $attachment_id,
275 'error' => 'Attachment not found',
276 ];
277 continue;
278 }
279
280 // Get old alt text
281 $old_alt_text = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
282
283 // Update alt text
284 update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text);
285
286 $results['success'][] = [
287 'attachment_id' => $attachment_id,
288 'filename' => basename($attachment->guid),
289 'old_alt_text' => $old_alt_text,
290 'new_alt_text' => $alt_text,
291 'alt_length' => mb_strlen($alt_text),
292 ];
293
294 } catch (Exception $e) {
295 $results['failed'][] = [
296 'attachment_id' => isset($update['attachment_id']) ? $update['attachment_id'] : null,
297 'error' => $e->getMessage(),
298 ];
299 }
300 }
301
302 return $this->success([
303 'total_requested' => count($updates),
304 'success_count' => count($results['success']),
305 'failed_count' => count($results['failed']),
306 'results' => $results,
307 'message' => count($results['success']) . ' image(s) alt text updated successfully',
308 ]);
309 }
310 }
311
312 /**
313 * Generate Alt Text Tool
314 *
315 * Generates alt text suggestions based on image filename and context
316 */
317 class MCP_Tool_Generate_Alt_Text extends MCP_Tool_Base {
318
319 public function get_name() {
320 return 'wordpress_generate_alt_text';
321 }
322
323 public function get_description() {
324 return 'Generate alt text suggestions for images based on filename, title, and context. Useful as a starting point for manual editing.';
325 }
326
327 public function get_input_schema() {
328 return [
329 'type' => 'object',
330 'properties' => [
331 'attachment_id' => [
332 'type' => 'integer',
333 'description' => 'Attachment ID to generate alt text for',
334 ],
335 'context' => [
336 'type' => 'string',
337 'description' => 'Optional context (e.g., post title, surrounding text) to improve suggestions',
338 ],
339 ],
340 'required' => ['attachment_id'],
341 ];
342 }
343
344 public function execute($params) {
345 $this->validate_params($params);
346 $this->require_capability('upload_files');
347
348 $attachment_id = intval($params['attachment_id']);
349 $context = isset($params['context']) ? sanitize_text_field($params['context']) : '';
350
351 // Verify attachment exists
352 $attachment = get_post($attachment_id);
353 if (!$attachment || $attachment->post_type !== 'attachment') {
354 throw new Exception(sprintf("Attachment not found: %d", $attachment_id));
355 }
356
357 // Get existing data
358 $current_alt = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
359 $title = $attachment->post_title;
360 $filename = basename($attachment->guid);
361 $caption = $attachment->post_excerpt;
362 $description = $attachment->post_content;
363
364 // Generate suggestions
365 $suggestions = [];
366
367 // Suggestion 1: Based on title
368 if (!empty($title) && $title !== $filename) {
369 $suggestions[] = [
370 'source' => 'title',
371 'text' => $this->clean_text($title),
372 'confidence' => 'high',
373 ];
374 }
375
376 // Suggestion 2: Based on filename
377 $filename_cleaned = $this->clean_filename($filename);
378 if (!empty($filename_cleaned)) {
379 $suggestions[] = [
380 'source' => 'filename',
381 'text' => $filename_cleaned,
382 'confidence' => 'medium',
383 ];
384 }
385
386 // Suggestion 3: Based on caption
387 if (!empty($caption)) {
388 $suggestions[] = [
389 'source' => 'caption',
390 'text' => $this->clean_text($caption),
391 'confidence' => 'high',
392 ];
393 }
394
395 // Suggestion 4: Based on description
396 if (!empty($description)) {
397 $suggestions[] = [
398 'source' => 'description',
399 'text' => wp_trim_words($this->clean_text($description), 15),
400 'confidence' => 'medium',
401 ];
402 }
403
404 // Suggestion 5: Based on context
405 if (!empty($context)) {
406 $suggestions[] = [
407 'source' => 'context',
408 'text' => $this->generate_contextual_alt($filename_cleaned, $context),
409 'confidence' => 'medium',
410 ];
411 }
412
413 // Remove duplicates
414 $suggestions = $this->deduplicate_suggestions($suggestions);
415
416 // Truncate to recommended length
417 foreach ($suggestions as &$suggestion) {
418 if (mb_strlen($suggestion['text']) > 125) {
419 $suggestion['text'] = mb_substr($suggestion['text'], 0, 125);
420 $suggestion['truncated'] = true;
421 }
422 }
423
424 return $this->success([
425 'attachment_id' => $attachment_id,
426 'filename' => $filename,
427 'current_alt_text' => $current_alt,
428 'needs_alt_text' => empty($current_alt),
429 'suggestions' => $suggestions,
430 'recommendation' => !empty($suggestions) ? $suggestions[0]['text'] : '',
431 ]);
432 }
433
434 /**
435 * Clean filename for alt text
436 */
437 private function clean_filename($filename) {
438 // Remove extension
439 $filename = preg_replace('/\.[^.]+$/', '', $filename);
440
441 // Replace separators with spaces
442 $filename = str_replace(['-', '_', '.'], ' ', $filename);
443
444 // Remove numbers if they're just IDs
445 $filename = preg_replace('/\b\d{4,}\b/', '', $filename);
446
447 // Clean up whitespace
448 $filename = trim(preg_replace('/\s+/', ' ', $filename));
449
450 // Capitalize words
451 $filename = ucwords(strtolower($filename));
452
453 return $filename;
454 }
455
456 /**
457 * Clean text for alt text
458 */
459 private function clean_text($text) {
460 // Strip HTML tags
461 $text = wp_strip_all_tags($text);
462
463 // Clean up whitespace
464 $text = trim(preg_replace('/\s+/', ' ', $text));
465
466 return $text;
467 }
468
469 /**
470 * Generate contextual alt text
471 */
472 private function generate_contextual_alt($filename, $context) {
473 $context_words = explode(' ', strtolower($context));
474 $filename_words = explode(' ', strtolower($filename));
475
476 // Find common words (simple relevance check)
477 $common = array_intersect($context_words, $filename_words);
478
479 if (!empty($common)) {
480 return $filename . ' for ' . wp_trim_words($context, 8);
481 }
482
483 return $filename . ' related to ' . wp_trim_words($context, 8);
484 }
485
486 /**
487 * Remove duplicate suggestions
488 */
489 private function deduplicate_suggestions($suggestions) {
490 $seen = [];
491 $unique = [];
492
493 foreach ($suggestions as $suggestion) {
494 $normalized = strtolower(trim($suggestion['text']));
495 if (!in_array($normalized, $seen)) {
496 $seen[] = $normalized;
497 $unique[] = $suggestion;
498 }
499 }
500
501 return $unique;
502 }
503 }
504
505 /**
506 * Validate Alt Text Tool
507 *
508 * Validates alt text quality and provides improvement suggestions
509 */
510 class MCP_Tool_Validate_Alt_Text extends MCP_Tool_Base {
511
512 public function get_name() {
513 return 'wordpress_validate_alt_text';
514 }
515
516 public function get_description() {
517 return 'Validate alt text quality and get specific improvement suggestions based on accessibility and SEO best practices';
518 }
519
520 public function get_input_schema() {
521 return [
522 'type' => 'object',
523 'properties' => [
524 'attachment_id' => [
525 'type' => 'integer',
526 'description' => 'Attachment ID to validate',
527 ],
528 ],
529 'required' => ['attachment_id'],
530 ];
531 }
532
533 public function execute($params) {
534 $this->validate_params($params);
535 $this->require_capability('upload_files');
536
537 $attachment_id = intval($params['attachment_id']);
538
539 // Verify attachment exists
540 $attachment = get_post($attachment_id);
541 if (!$attachment || $attachment->post_type !== 'attachment') {
542 throw new Exception(sprintf("Attachment not found: %d", $attachment_id));
543 }
544
545 $alt_text = get_post_meta($attachment_id, '_wp_attachment_image_alt', true);
546 $filename = basename($attachment->guid);
547
548 // Run validation checks
549 $issues = [];
550 $warnings = [];
551 $passed = [];
552 $score = 100;
553
554 // Check 1: Alt text exists
555 if (empty($alt_text)) {
556 $issues[] = [
557 'severity' => 'error',
558 'check' => 'alt_text_exists',
559 'message' => 'Alt text is missing',
560 'recommendation' => 'Add descriptive alt text that describes the image content and purpose',
561 ];
562 $score -= 100;
563 } else {
564 $passed[] = ['check' => 'alt_text_exists', 'message' => 'Alt text is present'];
565
566 $alt_length = mb_strlen($alt_text);
567
568 // Check 2: Length
569 if ($alt_length < 10) {
570 $issues[] = [
571 'severity' => 'error',
572 'check' => 'length_too_short',
573 'message' => "Alt text is too short ({$alt_length} characters)",
574 'recommendation' => 'Expand alt text to be more descriptive (10-125 characters recommended)',
575 ];
576 $score -= 30;
577 } elseif ($alt_length > 125) {
578 $warnings[] = [
579 'severity' => 'warning',
580 'check' => 'length_too_long',
581 'message' => "Alt text is too long ({$alt_length} characters)",
582 'recommendation' => 'Shorten alt text for better readability (125 characters max recommended)',
583 ];
584 $score -= 10;
585 } else {
586 $passed[] = ['check' => 'length_appropriate', 'message' => 'Alt text length is appropriate'];
587 }
588
589 // Check 3: Not just filename
590 if (strtolower($alt_text) === strtolower(pathinfo($filename, PATHINFO_FILENAME))) {
591 $warnings[] = [
592 'severity' => 'warning',
593 'check' => 'is_filename',
594 'message' => 'Alt text appears to be just the filename',
595 'recommendation' => 'Replace with descriptive text about the image content',
596 ];
597 $score -= 20;
598 } else {
599 $passed[] = ['check' => 'not_filename', 'message' => 'Alt text is not just the filename'];
600 }
601
602 // Check 4: Starts with redundant phrases
603 $redundant_starts = ['image of', 'picture of', 'photo of', 'graphic of', 'icon of'];
604 foreach ($redundant_starts as $phrase) {
605 if (stripos($alt_text, $phrase) === 0) {
606 $warnings[] = [
607 'severity' => 'warning',
608 'check' => 'redundant_phrase',
609 'message' => "Alt text starts with redundant phrase: \"{$phrase}\"",
610 'recommendation' => 'Remove redundant phrases - screen readers already announce it\'s an image',
611 ];
612 $score -= 10;
613 break;
614 }
615 }
616
617 // Check 5: Contains special characters that screen readers struggle with
618 if (preg_match('/[<>{}[\]\\|]/', $alt_text)) {
619 $warnings[] = [
620 'severity' => 'warning',
621 'check' => 'special_characters',
622 'message' => 'Alt text contains special characters that may cause issues',
623 'recommendation' => 'Remove special characters like <, >, {, }, [, ], \\, |',
624 ];
625 $score -= 5;
626 }
627
628 // Check 6: All caps
629 if ($alt_text === strtoupper($alt_text) && $alt_length > 5) {
630 $warnings[] = [
631 'severity' => 'warning',
632 'check' => 'all_caps',
633 'message' => 'Alt text is in all caps',
634 'recommendation' => 'Use sentence case for better readability',
635 ];
636 $score -= 10;
637 }
638 }
639
640 // Determine overall quality
641 $quality = 'excellent';
642 if ($score < 50) {
643 $quality = 'poor';
644 } elseif ($score < 70) {
645 $quality = 'needs_improvement';
646 } elseif ($score < 90) {
647 $quality = 'good';
648 }
649
650 return $this->success([
651 'attachment_id' => $attachment_id,
652 'filename' => $filename,
653 'alt_text' => $alt_text,
654 'alt_length' => mb_strlen($alt_text),
655 'quality' => $quality,
656 'score' => max($score, 0),
657 'issues' => $issues,
658 'warnings' => $warnings,
659 'passed' => $passed,
660 'total_checks' => count($issues) + count($warnings) + count($passed),
661 ]);
662 }
663 }
664