feat(batch5): media/content utilities — set/bulk alt, broken internal links, large images, duplicate titles
This commit is contained in:
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Batch 5 — Media & content utilities (Pro-equivalent, net-new).
|
||||||
|
*
|
||||||
|
* Independent implementations of common media/content hygiene capabilities:
|
||||||
|
* alt-text set + bulk backfill, broken internal-link scan, large-image finder,
|
||||||
|
* duplicate-title finder. GPL-2.0-or-later. See NOTICE.md.
|
||||||
|
*
|
||||||
|
* Registered via the asktam_mcp_register_tools hook (glob auto-loader).
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace AskTamMCP\Extensions;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
class Batch5 {
|
||||||
|
|
||||||
|
public static function register($registry): void {
|
||||||
|
$def = function (string $name, string $desc, array $props, array $required, string $scope, string $method) {
|
||||||
|
$schema = ['type' => 'object', 'properties' => $props];
|
||||||
|
if ($required) $schema['required'] = $required;
|
||||||
|
return ['name' => $name, 'description' => $desc, 'scope' => $scope,
|
||||||
|
'handler' => 'AskTamMCP\\Extensions\\Batch5::' . $method, 'inputSchema' => $schema];
|
||||||
|
};
|
||||||
|
|
||||||
|
$registry->register($def('set_image_alt', 'Set (or clear) the alt text of a media-library image attachment.',
|
||||||
|
['attachment_id' => ['type' => 'integer'], 'alt' => ['type' => 'string']],
|
||||||
|
['attachment_id', 'alt'], 'write', 'setAlt'));
|
||||||
|
|
||||||
|
$registry->register($def('bulk_set_alt_from_title', 'Backfill alt text for image attachments from their title (or a humanized filename). By default only fills images that have no alt; set overwrite=true to replace existing alts.',
|
||||||
|
['per_page' => ['type' => 'integer', 'default' => 100], 'overwrite' => ['type' => 'boolean', 'default' => false]],
|
||||||
|
[], 'write', 'bulkAlt'));
|
||||||
|
|
||||||
|
$registry->register($def('find_broken_internal_links', 'Scan published posts/pages for internal links whose target slug does not resolve to a post/page (candidate broken links). Heuristic: term/archive URLs may appear as false positives.',
|
||||||
|
['post_types' => ['type' => 'array', 'items' => ['type' => 'string'], 'default' => ['post', 'page']], 'per_page' => ['type' => 'integer', 'default' => 100]],
|
||||||
|
[], 'read', 'brokenLinks'));
|
||||||
|
|
||||||
|
$registry->register($def('list_large_images', 'List media-library images whose file size exceeds a threshold (KB), largest first.',
|
||||||
|
['min_kb' => ['type' => 'integer', 'default' => 500], 'per_page' => ['type' => 'integer', 'default' => 200]],
|
||||||
|
[], 'read', 'largeImages'));
|
||||||
|
|
||||||
|
$registry->register($def('duplicate_title_finder', 'Find published posts/pages that share an identical title.',
|
||||||
|
['post_types' => ['type' => 'array', 'items' => ['type' => 'string'], 'default' => ['post', 'page']], 'per_page' => ['type' => 'integer', 'default' => 500]],
|
||||||
|
[], 'read', 'dupTitles'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setAlt(array $args): array {
|
||||||
|
$id = (int) ($args['attachment_id'] ?? 0);
|
||||||
|
$post = $id ? get_post($id) : null;
|
||||||
|
if (!$post || $post->post_type !== 'attachment' || strpos((string) get_post_mime_type($id), 'image/') !== 0) {
|
||||||
|
throw new \RuntimeException("Not an image attachment: {$id}");
|
||||||
|
}
|
||||||
|
$alt = sanitize_text_field((string) ($args['alt'] ?? ''));
|
||||||
|
update_post_meta($id, '_wp_attachment_image_alt', $alt);
|
||||||
|
return ['success' => true, 'attachment_id' => $id, 'alt' => $alt];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function humanizeFilename(int $id): string {
|
||||||
|
$file = get_attached_file($id);
|
||||||
|
if (!$file) return '';
|
||||||
|
$name = pathinfo($file, PATHINFO_FILENAME);
|
||||||
|
$name = preg_replace('/[-_]+/', ' ', $name);
|
||||||
|
$name = preg_replace('/\b\d{2,4}x\d{2,4}\b/', '', $name); // drop size suffixes
|
||||||
|
return ucwords(trim(preg_replace('/\s+/', ' ', $name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function bulkAlt(array $args): array {
|
||||||
|
$per = (int) ($args['per_page'] ?? 100);
|
||||||
|
$overwrite = !empty($args['overwrite']);
|
||||||
|
$atts = get_posts(['post_type' => 'attachment', 'post_mime_type' => 'image', 'post_status' => 'inherit', 'posts_per_page' => $per]);
|
||||||
|
$changed = [];
|
||||||
|
foreach ($atts as $a) {
|
||||||
|
$cur = trim((string) get_post_meta($a->ID, '_wp_attachment_image_alt', true));
|
||||||
|
if ($cur !== '' && !$overwrite) continue;
|
||||||
|
$alt = trim((string) $a->post_title);
|
||||||
|
if ($alt === '') $alt = self::humanizeFilename($a->ID);
|
||||||
|
if ($alt === '') continue;
|
||||||
|
update_post_meta($a->ID, '_wp_attachment_image_alt', sanitize_text_field($alt));
|
||||||
|
$changed[] = ['id' => $a->ID, 'alt' => $alt];
|
||||||
|
}
|
||||||
|
return ['scanned' => count($atts), 'updated' => count($changed), 'overwrite' => $overwrite, 'samples' => array_slice($changed, 0, 20)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function brokenLinks(array $args): array {
|
||||||
|
$types = $args['post_types'] ?? ['post', 'page'];
|
||||||
|
$per = (int) ($args['per_page'] ?? 100);
|
||||||
|
$home = home_url();
|
||||||
|
$host = parse_url($home, PHP_URL_HOST);
|
||||||
|
$posts = get_posts(['post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => $per, 'fields' => 'ids']);
|
||||||
|
$issues = [];
|
||||||
|
if (!class_exists('DOMDocument')) return ['note' => 'DOMDocument unavailable', 'issues' => []];
|
||||||
|
foreach ($posts as $pid) {
|
||||||
|
$pid = (int) $pid;
|
||||||
|
$html = (string) get_post_field('post_content', $pid);
|
||||||
|
if (trim($html) === '') continue;
|
||||||
|
$dom = new \DOMDocument();
|
||||||
|
libxml_use_internal_errors(true);
|
||||||
|
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $html);
|
||||||
|
libxml_clear_errors();
|
||||||
|
foreach ($dom->getElementsByTagName('a') as $a) {
|
||||||
|
$href = trim($a->getAttribute('href'));
|
||||||
|
if ($href === '' || preg_match('#^(mailto:|tel:|#|javascript:)#i', $href)) continue;
|
||||||
|
$h = parse_url($href, PHP_URL_HOST);
|
||||||
|
if ($h && $h !== $host) continue; // external
|
||||||
|
$pathUrl = $h ? $href : rtrim($home, '/') . '/' . ltrim($href, '/');
|
||||||
|
$resolved = url_to_postid($pathUrl);
|
||||||
|
if (!$resolved) {
|
||||||
|
$issues[] = ['post_id' => $pid, 'href' => $href, 'anchor' => trim($a->textContent)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['scanned' => count($posts), 'candidate_broken' => $issues, 'count' => count($issues),
|
||||||
|
'note' => 'Heuristic: links to category/tag/author archives or custom routes can show as false positives.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function largeImages(array $args): array {
|
||||||
|
$minKb = max(1, (int) ($args['min_kb'] ?? 500));
|
||||||
|
$per = (int) ($args['per_page'] ?? 200);
|
||||||
|
$atts = get_posts(['post_type' => 'attachment', 'post_mime_type' => 'image', 'post_status' => 'inherit', 'posts_per_page' => $per]);
|
||||||
|
$big = [];
|
||||||
|
foreach ($atts as $a) {
|
||||||
|
$file = get_attached_file($a->ID);
|
||||||
|
if (!$file || !file_exists($file)) continue;
|
||||||
|
$kb = (int) round(filesize($file) / 1024);
|
||||||
|
if ($kb >= $minKb) {
|
||||||
|
$big[] = ['id' => $a->ID, 'title' => $a->post_title, 'kb' => $kb, 'url' => wp_get_attachment_url($a->ID)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($big, fn($x, $y) => $y['kb'] <=> $x['kb']);
|
||||||
|
return ['scanned' => count($atts), 'threshold_kb' => $minKb, 'large_images' => $big, 'count' => count($big)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function dupTitles(array $args): array {
|
||||||
|
$types = $args['post_types'] ?? ['post', 'page'];
|
||||||
|
$per = (int) ($args['per_page'] ?? 500);
|
||||||
|
$posts = get_posts(['post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => $per]);
|
||||||
|
$byTitle = [];
|
||||||
|
foreach ($posts as $p) {
|
||||||
|
$key = mb_strtolower(trim($p->post_title));
|
||||||
|
if ($key === '') continue;
|
||||||
|
$byTitle[$key][] = ['id' => $p->ID, 'type' => $p->post_type];
|
||||||
|
}
|
||||||
|
$dups = [];
|
||||||
|
foreach ($byTitle as $title => $group) {
|
||||||
|
if (count($group) > 1) $dups[] = ['title' => $title, 'posts' => $group, 'count' => count($group)];
|
||||||
|
}
|
||||||
|
return ['scanned' => count($posts), 'duplicates' => $dups, 'duplicate_sets' => count($dups)];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user